mcp-recipes 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
 
9
9
  // src/version.ts
10
- var PKG_VERSION = "1.2.0";
10
+ var PKG_VERSION = "1.3.0";
11
11
  var REPO_URL = "https://github.com/smeet666/mcp-recipes";
12
12
  var ISSUES_URL = "https://github.com/smeet666/mcp-recipes/issues";
13
13
 
@@ -102,11 +102,17 @@ var RecipesError = class extends Error {
102
102
  this.details = details;
103
103
  }
104
104
  };
105
+ var notFound = (message, details) => new RecipesError("not_found", message, details ?? {});
105
106
  var invalidInput = (message, hint) => new RecipesError("invalid_input", message, hint ? { hint } : {});
107
+ var rateLimited = (message, details) => new RecipesError("rate_limited", message, {
108
+ hint: "Wait a moment and ask again. This says nothing about whether the recipe exists.",
109
+ ...details
110
+ });
106
111
  var parseFailure = (message, details) => new RecipesError("parse_failure", message, {
107
112
  hint: `A site may have changed how it answers. Please report this at ${ISSUES_URL} with the arguments you used.`,
108
113
  ...details
109
114
  });
115
+ var networkError = (message, details) => new RecipesError("network_error", message, details ?? {});
110
116
  var timeout = (message, details) => new RecipesError("timeout", message, details ?? {});
111
117
  function toRecipesError(error) {
112
118
  if (error instanceof RecipesError) return error;
@@ -117,6 +123,30 @@ function toRecipesError(error) {
117
123
  const details = raw && typeof raw.details === "object" && raw.details !== null ? raw.details : {};
118
124
  return new RecipesError(known ?? "network_error", message, details);
119
125
  }
126
+ function fromSource(error, sourceName) {
127
+ if (error instanceof RecipesError) return error;
128
+ const known = toRecipesError(error);
129
+ const reported = known.message.replace(/\s+/g, " ").trim();
130
+ const opening = known.code === "not_found" ? `${sourceName} holds nothing under this name.` : `${sourceName} could not be read.`;
131
+ const message = reported === "" ? opening : `${opening} It reports: "${reported}"`;
132
+ const details = {};
133
+ if (known.details.url !== void 0) details.url = known.details.url;
134
+ if (known.details.status !== void 0) details.status = known.details.status;
135
+ switch (known.code) {
136
+ case "not_found":
137
+ return notFound(message, details);
138
+ case "invalid_input":
139
+ return invalidInput(message);
140
+ case "rate_limited":
141
+ return rateLimited(message, details);
142
+ case "parse_failure":
143
+ return parseFailure(message, details);
144
+ case "timeout":
145
+ return timeout(message, details);
146
+ default:
147
+ return networkError(message, details);
148
+ }
149
+ }
120
150
 
121
151
  // src/sources/adapter.ts
122
152
  function text(value) {
@@ -349,6 +379,9 @@ function cookbookDetail(payload) {
349
379
  yieldUnit: text(recipe.yieldUnit) ?? span.unit,
350
380
  ingredients: textList(recipe.ingredients),
351
381
  steps: textList(recipe.steps),
382
+ // The page's own headings, which the reader reports. An answer holding no
383
+ // ingredient line uses them to say whether the page announced a list.
384
+ publishedSections: Array.isArray(recipe.sectionTitles) ? textList(recipe.sectionTitles) : null,
352
385
  // The Cookbook publishes one time for a recipe rather than splitting it.
353
386
  prepMinutes: null,
354
387
  cookMinutes: null,
@@ -466,6 +499,10 @@ function marmitonDetail(payload) {
466
499
  yieldUnit: span.unit ?? text(yields.unit),
467
500
  ingredients: textList(recipe.ingredients),
468
501
  steps: textList(recipe.steps),
502
+ // The reader hands over the parts of a page it recognised and not the
503
+ // headings the page carries, so there is nothing here to say how it is
504
+ // laid out.
505
+ publishedSections: null,
469
506
  prepMinutes: count(recipe.prepMinutes),
470
507
  cookMinutes: count(recipe.cookMinutes),
471
508
  totalMinutes: count(recipe.totalMinutes),
@@ -518,6 +555,473 @@ function selectSources(sources, wanted) {
518
555
  return chosen;
519
556
  }
520
557
 
558
+ // src/sources/wordings.ts
559
+ var MAX_WORDINGS_PER_SOURCE = 3;
560
+ var FRENCH_FRAME = /* @__PURE__ */ new Set([
561
+ "a",
562
+ "ai",
563
+ "aimerais",
564
+ "au",
565
+ "aurais",
566
+ "auriez",
567
+ "aux",
568
+ "avec",
569
+ "avons",
570
+ "bcp",
571
+ "beaucoup",
572
+ "bien",
573
+ "bonjour",
574
+ "bonne",
575
+ "bonnes",
576
+ "cherche",
577
+ "chercher",
578
+ "cherchez",
579
+ "connais",
580
+ "connaissez",
581
+ "coucou",
582
+ "ce",
583
+ "ces",
584
+ "cette",
585
+ "comment",
586
+ "d",
587
+ "dans",
588
+ "de",
589
+ "des",
590
+ "du",
591
+ "en",
592
+ "est",
593
+ "et",
594
+ "facile",
595
+ "faire",
596
+ "fais",
597
+ "fait",
598
+ "idee",
599
+ "idees",
600
+ "id\xE9e",
601
+ "id\xE9es",
602
+ "j",
603
+ "je",
604
+ "l",
605
+ "la",
606
+ "le",
607
+ "les",
608
+ "ma",
609
+ "meilleur",
610
+ "meilleure",
611
+ "merci",
612
+ "mes",
613
+ "mon",
614
+ "ou",
615
+ "par",
616
+ "peux",
617
+ "plait",
618
+ "pla\xEEt",
619
+ "plus",
620
+ "pour",
621
+ "qu",
622
+ "que",
623
+ "quel",
624
+ "quelle",
625
+ "quelles",
626
+ "quels",
627
+ "qui",
628
+ "rapide",
629
+ "recette",
630
+ "recettes",
631
+ "recherche",
632
+ "s",
633
+ "salut",
634
+ "se",
635
+ "si",
636
+ "simple",
637
+ "sommes",
638
+ "souhaite",
639
+ "stp",
640
+ "suis",
641
+ "sur",
642
+ "svp",
643
+ "tres",
644
+ "tr\xE8s",
645
+ "un",
646
+ "une",
647
+ "veux",
648
+ "voudrais",
649
+ "vous",
650
+ "\xE0"
651
+ ]);
652
+ var ENGLISH_FRAME = /* @__PURE__ */ new Set([
653
+ "a",
654
+ "about",
655
+ "am",
656
+ "an",
657
+ "and",
658
+ "any",
659
+ "anything",
660
+ "are",
661
+ "at",
662
+ "be",
663
+ "best",
664
+ "by",
665
+ "can",
666
+ "could",
667
+ "do",
668
+ "does",
669
+ "easy",
670
+ "find",
671
+ "for",
672
+ "from",
673
+ "get",
674
+ "good",
675
+ "hello",
676
+ "hi",
677
+ "how",
678
+ "i",
679
+ "in",
680
+ "into",
681
+ "is",
682
+ "it",
683
+ "like",
684
+ "looking",
685
+ "make",
686
+ "making",
687
+ "me",
688
+ "my",
689
+ "need",
690
+ "of",
691
+ "on",
692
+ "or",
693
+ "please",
694
+ "quick",
695
+ "recipe",
696
+ "recipes",
697
+ "should",
698
+ "simple",
699
+ "some",
700
+ "something",
701
+ "suggest",
702
+ "thank",
703
+ "thanks",
704
+ "that",
705
+ "the",
706
+ "less",
707
+ "over",
708
+ "than",
709
+ "to",
710
+ "under",
711
+ "want",
712
+ "what",
713
+ "which",
714
+ "will",
715
+ "with",
716
+ "would",
717
+ "you",
718
+ "your"
719
+ ]);
720
+ var CONDITION_UNITS = /* @__PURE__ */ new Set([
721
+ "calorie",
722
+ "calories",
723
+ "couvert",
724
+ "couverts",
725
+ "heure",
726
+ "heures",
727
+ "kcal",
728
+ "min",
729
+ "mins",
730
+ "minute",
731
+ "minutes",
732
+ "part",
733
+ "parts",
734
+ "people",
735
+ "person",
736
+ "persons",
737
+ "personne",
738
+ "personnes",
739
+ "portion",
740
+ "portions",
741
+ "serving",
742
+ "servings"
743
+ ]);
744
+ var NEGATORS = /* @__PURE__ */ new Set([
745
+ "aucun",
746
+ "aucune",
747
+ "avoid",
748
+ "avoiding",
749
+ "eviter",
750
+ "evite",
751
+ "evitez",
752
+ "exclude",
753
+ "excluding",
754
+ "exclure",
755
+ "except",
756
+ "ni",
757
+ "no",
758
+ "non",
759
+ "not",
760
+ "pas",
761
+ "sans",
762
+ "sauf",
763
+ "without",
764
+ "zero"
765
+ ]);
766
+ var HEALTH_MARKERS = /* @__PURE__ */ new Set([
767
+ "allergene",
768
+ "allergenes",
769
+ "allergen",
770
+ "allergens",
771
+ "allergic",
772
+ "allergie",
773
+ "allergies",
774
+ "allergique",
775
+ "allergiques",
776
+ "allergy",
777
+ "celiac",
778
+ "coeliaque",
779
+ "intolerance",
780
+ "intolerances",
781
+ "intolerant",
782
+ "intolerante",
783
+ "intolerantes",
784
+ "intolerants"
785
+ ]);
786
+ var DIETS = /* @__PURE__ */ new Set([
787
+ "casher",
788
+ "cetogene",
789
+ "halal",
790
+ "kascher",
791
+ "keto",
792
+ "ketogenic",
793
+ "kosher",
794
+ "paleo",
795
+ "pescatarian",
796
+ "pescetarien",
797
+ "pescetarienne",
798
+ "vegan",
799
+ "vegane",
800
+ "veganes",
801
+ "vegans",
802
+ "vegetalien",
803
+ "vegetalienne",
804
+ "vegetaliennes",
805
+ "vegetaliens",
806
+ "vegetarian",
807
+ "vegetarians",
808
+ "vegetarien",
809
+ "vegetarienne",
810
+ "vegetariennes",
811
+ "vegetariens",
812
+ "veggie"
813
+ ]);
814
+ var CONDITION_ENDS = /* @__PURE__ */ new Set([
815
+ "et",
816
+ "and",
817
+ "ou",
818
+ "or",
819
+ "pour",
820
+ "for",
821
+ "avec",
822
+ "with",
823
+ "mais",
824
+ "but",
825
+ "dans",
826
+ "sur"
827
+ ]);
828
+ var CONDITION_JOINERS = /* @__PURE__ */ new Set([
829
+ "a",
830
+ "au",
831
+ "aux",
832
+ "d",
833
+ "de",
834
+ "des",
835
+ "du",
836
+ "from",
837
+ "l",
838
+ "la",
839
+ "le",
840
+ "les",
841
+ "my",
842
+ "of",
843
+ "the",
844
+ "to"
845
+ ]);
846
+ var FOOD_LINKERS = /* @__PURE__ */ new Set(["d", "de", "des", "du", "of"]);
847
+ var CONDITION_WORDS = 3;
848
+ var SERVING_PHRASES = [
849
+ /\bpour\s+(\d{1,3})\s*(?:personnes?|parts?|couverts?)\b/iu,
850
+ /\bfor\s+(\d{1,3})\s*(?:people|persons?|servings?|portions?)\b/iu,
851
+ /\bserves?\s+(\d{1,3})\b/iu,
852
+ /\b(\d{1,3})\s*(?:personnes?|people|servings?)\b/iu
853
+ ];
854
+ var FREE_OF = /\b([\p{L}]{3,})[\s-]?free\b/iu;
855
+ function foldWord(word) {
856
+ return word.toLowerCase().normalize("NFD").replace(new RegExp("\\p{M}+", "gu"), "").replace(/œ/gu, "oe");
857
+ }
858
+ function readQuestion(question) {
859
+ const words = tokenise(question);
860
+ const conditions = [];
861
+ const dish = [];
862
+ const state = (named, kind) => {
863
+ const trimmed = named === null ? null : named.trim();
864
+ if (trimmed === "") return;
865
+ if (conditions.some((held) => held.named === trimmed && held.kind === kind)) return;
866
+ conditions.push({ named: trimmed, kind });
867
+ };
868
+ for (let index = 0; index < words.length; index += 1) {
869
+ const word = words[index];
870
+ const key = foldWord(word);
871
+ const hyphen = word.indexOf("-");
872
+ if (hyphen > 0 && NEGATORS.has(foldWord(word.slice(0, hyphen)))) {
873
+ state(word.slice(hyphen + 1), "excluded");
874
+ continue;
875
+ }
876
+ const free = FREE_OF.exec(word);
877
+ if (free) {
878
+ state(free[1], "excluded");
879
+ continue;
880
+ }
881
+ if (key === "free") {
882
+ const scope = scopeOf(words, index, dish);
883
+ if (scope.named !== null) {
884
+ state(scope.named, "excluded");
885
+ index = scope.next - 1;
886
+ }
887
+ continue;
888
+ }
889
+ if (NEGATORS.has(key)) {
890
+ const forward = takeFood(words, index + 1);
891
+ state(forward.named === "" ? null : forward.named, "excluded");
892
+ index = forward.next - 1;
893
+ continue;
894
+ }
895
+ if (HEALTH_MARKERS.has(key)) {
896
+ const scope = scopeOf(words, index, dish);
897
+ state(scope.named, "allergy");
898
+ index = scope.next - 1;
899
+ continue;
900
+ }
901
+ if (DIETS.has(key)) {
902
+ state(word, "diet");
903
+ continue;
904
+ }
905
+ if (FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key)) continue;
906
+ if (FRENCH_FRAME.has(word) || ENGLISH_FRAME.has(word)) continue;
907
+ if (/^\d+$/.test(word)) continue;
908
+ if (word.length < 2) continue;
909
+ if (CONDITION_UNITS.has(word)) continue;
910
+ if (!dish.includes(word)) dish.push(word);
911
+ }
912
+ return { dish, conditions };
913
+ }
914
+ function scopeOf(words, marker, dish) {
915
+ if (CONDITION_JOINERS.has(foldWord(words[marker + 1] ?? ""))) {
916
+ const forward = takeFood(words, marker + 1);
917
+ return { named: forward.named === "" ? null : forward.named, next: forward.next };
918
+ }
919
+ const before = words[marker - 1];
920
+ if (before !== void 0) {
921
+ const held = dish.lastIndexOf(before);
922
+ if (held !== -1) {
923
+ dish.splice(held, 1);
924
+ return { named: before, next: marker + 1 };
925
+ }
926
+ }
927
+ return { named: null, next: marker + 1 };
928
+ }
929
+ function takeFood(words, from) {
930
+ let index = from;
931
+ while (index < words.length && isFiller(words[index])) index += 1;
932
+ const named = [];
933
+ while (index < words.length && named.length < CONDITION_WORDS) {
934
+ if (!namesFood(words[index])) break;
935
+ named.push(words[index]);
936
+ index += 1;
937
+ const beyond = words[index + 1];
938
+ if (named.length + 2 <= CONDITION_WORDS && FOOD_LINKERS.has(foldWord(words[index] ?? "")) && beyond !== void 0 && namesFood(beyond)) {
939
+ named.push(words[index]);
940
+ index += 1;
941
+ continue;
942
+ }
943
+ break;
944
+ }
945
+ return { named: named.join(" "), next: index };
946
+ }
947
+ function isFiller(word) {
948
+ const key = foldWord(word);
949
+ return CONDITION_JOINERS.has(key) || FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key);
950
+ }
951
+ function namesFood(word) {
952
+ const key = foldWord(word);
953
+ if (key.length < 2) return false;
954
+ if (/^\d+$/.test(key)) return false;
955
+ if (key === "free") return false;
956
+ if (CONDITION_ENDS.has(key)) return false;
957
+ if (NEGATORS.has(key) || HEALTH_MARKERS.has(key)) return false;
958
+ if (CONDITION_UNITS.has(key)) return false;
959
+ return !(FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key));
960
+ }
961
+ function readConditions(question) {
962
+ const { conditions } = readQuestion(question);
963
+ let servings = null;
964
+ for (const phrase of SERVING_PHRASES) {
965
+ const found = phrase.exec(question);
966
+ if (found) {
967
+ const value = Number(found[1]);
968
+ if (Number.isFinite(value) && value > 0) {
969
+ servings = value;
970
+ break;
971
+ }
972
+ }
973
+ }
974
+ return { conditions, servings };
975
+ }
976
+ function deriveWordings(question) {
977
+ const asked = tidy(question);
978
+ const wordings = [];
979
+ const seen = /* @__PURE__ */ new Set();
980
+ const offer = (candidate, derivation) => {
981
+ const clean = tidy(candidate);
982
+ if (clean === "") return;
983
+ const key = clean.toLowerCase();
984
+ if (seen.has(key)) return;
985
+ seen.add(key);
986
+ wordings.push({ query: clean, derivation });
987
+ };
988
+ offer(asked, "the words as asked, condition words included");
989
+ const dish = dishWords(asked);
990
+ offer(
991
+ dish.join(" "),
992
+ "the words naming the dish, with the words framing the question and the conditions set aside, because a page is named for what it holds"
993
+ );
994
+ offer(
995
+ dish[0] ?? "",
996
+ "the leading dish word on its own, because an index that requires every word given returns nothing for a phrase and a great deal for the word naming the dish"
997
+ );
998
+ return wordings.slice(0, MAX_WORDINGS_PER_SOURCE);
999
+ }
1000
+ function namesDish(title, question) {
1001
+ const words = namingWords(question);
1002
+ if (words.length === 0) return true;
1003
+ return dishWordsMissing(title, question).length < words.length;
1004
+ }
1005
+ function dishWordsMissing(title, question) {
1006
+ const haystack = ` ${fold(title)} `;
1007
+ return namingWords(question).filter((word) => !haystack.includes(` ${fold(word)}`));
1008
+ }
1009
+ function namingWords(question) {
1010
+ return dishWords(question).filter((word) => word.length > 2);
1011
+ }
1012
+ function fold(value) {
1013
+ return value.normalize("NFD").replace(new RegExp("\\p{M}+", "gu"), "").replace(/œ/gu, "oe").toLowerCase().replace(/[^a-z0-9]+/g, " ");
1014
+ }
1015
+ function dishWords(question) {
1016
+ return readQuestion(question).dish;
1017
+ }
1018
+ function tokenise(value) {
1019
+ return value.toLowerCase().replace(/['’]/gu, " ").split(/[^\p{L}\p{N}-]+/u).map((word) => word.replace(/^-+|-+$/g, "")).filter((word) => word !== "");
1020
+ }
1021
+ function tidy(value) {
1022
+ return value.replace(/\s+/g, " ").trim();
1023
+ }
1024
+
521
1025
  // src/sources/client.ts
522
1026
  var MAX_LIMIT_PER_SOURCE = 25;
523
1027
  function withGuarantees(config) {
@@ -550,9 +1054,7 @@ function withDeadline(work, ms, source) {
550
1054
  const alarm = new Promise((_resolve, reject) => {
551
1055
  timer = setTimeout(
552
1056
  () => reject(
553
- timeout(
554
- `${source.name} did not answer within ${ms}ms and was left out of this answer.`
555
- )
1057
+ timeout(`${source.name} did not answer within ${ms}ms and was left out of this answer.`)
556
1058
  ),
557
1059
  ms
558
1060
  );
@@ -571,7 +1073,20 @@ function reportOf(attempt, count2) {
571
1073
  skipped: attempt.skipped,
572
1074
  mixesReferencePages: attempt.source.mixesReferencePages,
573
1075
  cached: attempt.cached,
574
- error: attempt.error
1076
+ error: attempt.error,
1077
+ wordings: attempt.wordings,
1078
+ preferredByName: attempt.preferredByName
1079
+ };
1080
+ }
1081
+ function heldBack(wording, because) {
1082
+ return {
1083
+ query: wording.query,
1084
+ derivation: wording.derivation,
1085
+ ran: false,
1086
+ count: null,
1087
+ added: null,
1088
+ notRunBecause: because,
1089
+ error: null
575
1090
  };
576
1091
  }
577
1092
  function interleave(groups) {
@@ -631,15 +1146,22 @@ var RecipesClient = class {
631
1146
  * source rather than as long as all of them. Whichever of them fail, the rest
632
1147
  * still come back, and the report says which was which.
633
1148
  */
634
- async searchRecipes(query, limitPerSource, wanted) {
1149
+ async searchRecipes(query, limitPerSource, wanted, options = {}) {
635
1150
  const trimmed = query.trim();
636
1151
  if (trimmed === "") {
637
1152
  throw invalidInput("A search needs something to look for.", "Name a dish or an ingredient.");
638
1153
  }
1154
+ if (!/[\p{L}\p{N}]/u.test(trimmed)) {
1155
+ throw invalidInput(
1156
+ `"${trimmed}" carries no letter and no digit, so there is no word to search for.`,
1157
+ "Name a dish or an ingredient."
1158
+ );
1159
+ }
639
1160
  const chosen = selectSources(this.sources, wanted);
640
1161
  const limit = Math.max(1, Math.min(MAX_LIMIT_PER_SOURCE, Math.trunc(limitPerSource)));
1162
+ const fanOut = options.fanOut ?? true;
641
1163
  const attempts = await Promise.all(
642
- chosen.map((source) => this.searchOne(source, trimmed, limit))
1164
+ chosen.map((source) => this.searchOne(source, trimmed, limit, fanOut))
643
1165
  );
644
1166
  const groups = attempts.map((attempt) => attempt.rows.slice(0, limit));
645
1167
  return {
@@ -647,40 +1169,135 @@ var RecipesClient = class {
647
1169
  reports: attempts.map((attempt, index) => reportOf(attempt, groups[index].length))
648
1170
  };
649
1171
  }
650
- async searchOne(source, query, limit) {
651
- try {
652
- const read = await withDeadline(source.search(query, limit), this.deadlineMs, source);
653
- if (read.skipped > 0) {
654
- this.logger.warn(
655
- `${source.name} sent ${read.skipped} row(s) this server could not read; they were left out.`
1172
+ /**
1173
+ * Ask one source the question in several wordings, and union what comes back.
1174
+ *
1175
+ * The wordings go out one after another rather than together, because each
1176
+ * source is paced and a burst would spend the whole ladder's politeness at
1177
+ * once. They stop early: a wording is only sent when the ones before it left
1178
+ * the source short of what was asked for, so the ordinary case where the
1179
+ * question names a dish costs one request.
1180
+ *
1181
+ * Each wording is a different search rather than a further page of the same
1182
+ * one. One of these sites serves a single page of results and disallows
1183
+ * paging past it, so a second page is never asked for, here or anywhere else.
1184
+ */
1185
+ async searchOne(source, question, limit, fanOut) {
1186
+ const derived = deriveWordings(question);
1187
+ const wordings = [];
1188
+ const rows = [];
1189
+ const seen = /* @__PURE__ */ new Set();
1190
+ let cached = false;
1191
+ let skipped = 0;
1192
+ let reportedTotal = null;
1193
+ let reportedTotalMeans = null;
1194
+ let answered = false;
1195
+ let failure = null;
1196
+ let onTopic = 0;
1197
+ for (const [index, wording] of derived.entries()) {
1198
+ if (index > 0 && !fanOut) {
1199
+ wordings.push(
1200
+ heldBack(wording, "fan_out was turned off, so only the words as asked were sent")
656
1201
  );
1202
+ continue;
657
1203
  }
658
- return {
659
- source,
660
- rows: read.rows,
661
- cached: read.cached,
662
- reportedTotal: read.reportedTotal,
663
- reportedTotalMeans: read.reportedTotalMeans,
664
- skipped: read.skipped,
665
- error: null
666
- };
667
- } catch (error) {
668
- const known = toRecipesError(error);
669
- this.logger.warn(`${source.name} did not answer: [${known.code}] ${known.message}`);
670
- return {
671
- source,
672
- rows: [],
673
- cached: false,
674
- reportedTotal: null,
675
- reportedTotalMeans: null,
676
- skipped: 0,
677
- error: {
1204
+ if (index >= MAX_WORDINGS_PER_SOURCE) {
1205
+ wordings.push(
1206
+ heldBack(
1207
+ wording,
1208
+ `the ceiling of ${MAX_WORDINGS_PER_SOURCE} searches of one source was reached`
1209
+ )
1210
+ );
1211
+ continue;
1212
+ }
1213
+ if (failure !== null) {
1214
+ wordings.push(
1215
+ heldBack(wording, `${source.name} did not answer the wording before this one`)
1216
+ );
1217
+ continue;
1218
+ }
1219
+ if (onTopic >= limit) {
1220
+ wordings.push(
1221
+ heldBack(
1222
+ wording,
1223
+ "the wordings already sent returned as many rows naming the dish as were asked for"
1224
+ )
1225
+ );
1226
+ continue;
1227
+ }
1228
+ try {
1229
+ const read = await withDeadline(
1230
+ source.search(wording.query, limit),
1231
+ this.deadlineMs,
1232
+ source
1233
+ );
1234
+ answered = true;
1235
+ cached = cached || read.cached;
1236
+ skipped += read.skipped;
1237
+ if (reportedTotal === null && reportedTotalMeans === null) {
1238
+ reportedTotal = read.reportedTotal;
1239
+ reportedTotalMeans = read.reportedTotalMeans;
1240
+ }
1241
+ if (read.skipped > 0) {
1242
+ this.logger.warn(
1243
+ `${source.name} sent ${read.skipped} row(s) this server could not read; they were left out.`
1244
+ );
1245
+ }
1246
+ let added = 0;
1247
+ for (const row of read.rows) {
1248
+ if (seen.has(row.id)) continue;
1249
+ seen.add(row.id);
1250
+ rows.push(row);
1251
+ added += 1;
1252
+ if (namesDish(row.title, question)) onTopic += 1;
1253
+ }
1254
+ wordings.push({
1255
+ query: wording.query,
1256
+ derivation: wording.derivation,
1257
+ ran: true,
1258
+ count: read.rows.length,
1259
+ added,
1260
+ notRunBecause: null,
1261
+ error: null
1262
+ });
1263
+ } catch (error) {
1264
+ const known = fromSource(error, source.name);
1265
+ this.logger.warn(
1266
+ `${source.name} did not answer "${wording.query}": [${known.code}] ${known.message}`
1267
+ );
1268
+ const reason = {
678
1269
  code: known.code,
679
1270
  message: known.message,
680
1271
  ...known.details.hint ? { hint: known.details.hint } : {}
681
- }
682
- };
1272
+ };
1273
+ wordings.push({
1274
+ query: wording.query,
1275
+ derivation: wording.derivation,
1276
+ ran: true,
1277
+ count: null,
1278
+ added: null,
1279
+ notRunBecause: null,
1280
+ error: reason
1281
+ });
1282
+ failure = reason;
1283
+ }
683
1284
  }
1285
+ const contributing = wordings.filter((attempt) => (attempt.added ?? 0) > 0).length;
1286
+ const ordered = contributing > 1 ? [
1287
+ ...rows.filter((row) => namesDish(row.title, question)),
1288
+ ...rows.filter((row) => !namesDish(row.title, question))
1289
+ ] : rows;
1290
+ return {
1291
+ source,
1292
+ rows: ordered,
1293
+ preferredByName: contributing > 1,
1294
+ cached,
1295
+ reportedTotal,
1296
+ reportedTotalMeans,
1297
+ skipped,
1298
+ error: answered ? null : failure,
1299
+ wordings
1300
+ };
684
1301
  }
685
1302
  /**
686
1303
  * Read one recipe, from whichever source the identifier names.
@@ -699,7 +1316,7 @@ var RecipesClient = class {
699
1316
  );
700
1317
  return { recipe: outcome.recipe, cached: outcome.cached, read };
701
1318
  } catch (error) {
702
- throw toRecipesError(error);
1319
+ throw fromSource(error, read.source.name);
703
1320
  }
704
1321
  }
705
1322
  };
@@ -719,7 +1336,10 @@ var ENGLISH_FOODS = /\b(?:eggs?|salt|pepper|sugar|flour|butter|milk|cream|oil|ga
719
1336
  var STRUCTURAL = 3;
720
1337
  var VOCABULARY = 2;
721
1338
  var NAME = 1;
722
- function readLanguage(line, evidence = { frenchUnit: false, englishUnit: false }) {
1339
+ function readLanguage(line, evidence = {
1340
+ frenchUnit: false,
1341
+ englishUnit: false
1342
+ }) {
723
1343
  let french = 0;
724
1344
  let english = 0;
725
1345
  if (FRENCH_WORDS.test(line)) french += STRUCTURAL;
@@ -1042,8 +1662,10 @@ var EMBEDDED = {
1042
1662
  };
1043
1663
  function buildEmbedded(language) {
1044
1664
  const keys = UNIT_KEYS[language].map((key) => key.replace(/ /g, "\\s+"));
1045
- return new RegExp(`\\d[\\d.,/]*\\s*(?:${keys.join("|")})\\b`, "i");
1665
+ const joiner = "(?:(?:de|du|des|d|of|a|an|the)\\s+)?";
1666
+ return new RegExp(`\\d[\\d.,/]*\\s*${joiner}(?:${keys.join("|")})\\b`, "i");
1046
1667
  }
1668
+ var CONTAINER_NOUN = /^(?:cans?|tins?|jars?|packets?|packages?|packs?|box|boxes|bottles?|cartons?|bags?|tubs?|sachets?|blocks?|blocs?|boites?|pots?|briques?|bocaux|bocals?|barquettes?)\b/i;
1047
1669
  function hasEmbeddedMeasure(text2, language) {
1048
1670
  return EMBEDDED[language].test(normalizeUnitKey(text2));
1049
1671
  }
@@ -1188,15 +1810,24 @@ function chooseReadableUnit(unit, amount) {
1188
1810
  current = step.unit;
1189
1811
  }
1190
1812
  const up = PROMOTIONS[normalizeUnitKey(current.canonical)];
1191
- if (up && amount * ratio >= up.per) {
1813
+ if (up && amount * ratio >= up.per && writesExactly(amount * ratio / up.per)) {
1192
1814
  const target = lookupUnit(up.to, up.language);
1193
1815
  if (target) {
1194
1816
  ratio /= up.per;
1195
1817
  current = target;
1196
1818
  }
1197
1819
  }
1820
+ while (!writesExactly(amount * ratio)) {
1821
+ const step = demoteUnit(current);
1822
+ if (!step) break;
1823
+ ratio *= step.per;
1824
+ current = step.unit;
1825
+ }
1198
1826
  return { unit: current, ratio };
1199
1827
  }
1828
+ function writesExactly(value) {
1829
+ return Math.abs(value * 100 - Math.round(value * 100)) < 1e-9;
1830
+ }
1200
1831
  function formatUnit(unit, amount, language) {
1201
1832
  if (unit.symbol) return unit.canonical;
1202
1833
  const singular = language === "fr" ? amount < 2 : amount <= 1;
@@ -1280,6 +1911,40 @@ function isStatedSize(text2) {
1280
1911
  if (!measure.unit || measure.unit.kind !== "measured") return false;
1281
1912
  return !CONTAINER_CONTENTS.test(measure.rest);
1282
1913
  }
1914
+ function takeCompoundMember(text2, unit, language) {
1915
+ if (!unit) return null;
1916
+ const step = demoteUnit(unit);
1917
+ if (!step) return null;
1918
+ const joiner = ADDS_UP.exec(text2);
1919
+ if (joiner) text2 = text2.slice(joiner[0].length);
1920
+ const second = parseLeadingQuantity(text2, language);
1921
+ if (!second || second.amount <= 0 || second.amount >= step.per) return null;
1922
+ const after = text2.slice(second.length);
1923
+ const measure = takeUnit(after.trimStart(), language);
1924
+ if (measure.unit) {
1925
+ if (measure.unit.canonical !== step.unit.canonical) return null;
1926
+ return { adds: second.amount / step.per, rest: measure.rest.trimStart() };
1927
+ }
1928
+ if (unit.kind !== "measured") return null;
1929
+ if (!Number.isInteger(second.amount)) return null;
1930
+ const trailing = after.trimStart();
1931
+ if (trailing !== "" && !/^(?:de\s|d'|du\s|des\s|,)/i.test(trailing)) return null;
1932
+ return { adds: second.amount / step.per, rest: trailing };
1933
+ }
1934
+ var ADDS_UP = /^\s*(?:\+|et|and|plus)\s+/i;
1935
+ var LEADING_DECORATION = /^(?:(?!~|≈|\()[^\p{L}\p{N}\s])+(?=\s)/u;
1936
+ function takeDecoration(text2) {
1937
+ const match = LEADING_DECORATION.exec(text2);
1938
+ return match ? match[0] : null;
1939
+ }
1940
+ function namesContainer(word) {
1941
+ return word !== void 0 && CONTAINER_NOUN.test(normalizeUnitKey(word));
1942
+ }
1943
+ var TIME_UNIT = {
1944
+ en: /^(?:h|hr|hrs|hours?|mins?|minutes?|secs?|seconds?|days?|nights?|weeks?)\b/i,
1945
+ fr: /^(?:h|mn|heures?|mins?|minutes?|secs?|secondes?|jours?|nuits?|semaines?)\b/i
1946
+ };
1947
+ var ORDINAL_SUFFIX = /^(?:ers?|[eè]res?|[eè]mes?|es?|st|nd|rd|th)\b/i;
1283
1948
  var PER_PERSON = {
1284
1949
  en: /\bper\s+(?:person|head|serving|guest|diner)\b/i,
1285
1950
  fr: /\bpar\s+(?:personne|convive|part|t\u00eate)\b/i
@@ -1402,16 +2067,16 @@ function parseLeadingRange(text2, language) {
1402
2067
  length: low.length + separator[0].length + high.length
1403
2068
  };
1404
2069
  }
1405
- var FRENCH_ARTICLES = { un: 1, une: 1, quelques: 3 };
2070
+ var FRENCH_ARTICLES = { un: 1, une: 1 };
1406
2071
  function takeUnit(text2, language) {
1407
2072
  const normalized = normalizeUnitKey(text2);
1408
2073
  for (const key of unitKeys(language)) {
1409
2074
  if (normalized !== key && !normalized.startsWith(`${key} `)) continue;
1410
2075
  const unit = lookupUnit(key, language);
1411
2076
  if (!unit) continue;
1412
- const wordCount = key.split(" ").length;
1413
- const words = text2.trim().split(/\s+/);
1414
- return { unit, rest: words.slice(wordCount).join(" ") };
2077
+ const rest = afterKey(text2, key);
2078
+ if (rest === null) continue;
2079
+ return { unit, rest };
1415
2080
  }
1416
2081
  if (language === "en") {
1417
2082
  const words = text2.trimStart().split(/\s+/);
@@ -1420,6 +2085,14 @@ function takeUnit(text2, language) {
1420
2085
  }
1421
2086
  return { unit: null, rest: text2 };
1422
2087
  }
2088
+ function afterKey(text2, key) {
2089
+ const words = text2.trim().split(/\s+/);
2090
+ for (let count2 = 1; count2 <= words.length; count2 += 1) {
2091
+ if (normalizeUnitKey(words.slice(0, count2).join(" ")) !== key) continue;
2092
+ return words.slice(count2).join(" ");
2093
+ }
2094
+ return null;
2095
+ }
1423
2096
  function takeUnitEitherLanguage(text2, language) {
1424
2097
  const first = takeUnit(text2, language);
1425
2098
  if (first.unit) return first;
@@ -1435,13 +2108,16 @@ function readEvidence(line) {
1435
2108
  }
1436
2109
  function parseIngredient(line, choice = "auto") {
1437
2110
  const original = line;
1438
- const text2 = readable(line);
2111
+ const marked = readable(line);
2112
+ const decoration = takeDecoration(marked);
2113
+ const text2 = decoration ? marked.slice(decoration.length).trimStart() : marked;
1439
2114
  const evidence = readEvidence(text2);
1440
2115
  const language = choice === "auto" ? evidence.language : choice;
1441
- const empty = (heldBack) => ({
2116
+ const empty = (heldBack2) => ({
1442
2117
  original,
1443
2118
  language,
1444
- heldBack,
2119
+ heldBack: heldBack2,
2120
+ decoration,
1445
2121
  approximation: null,
1446
2122
  measureAdjective: null,
1447
2123
  amount: null,
@@ -1450,6 +2126,8 @@ function parseIngredient(line, choice = "auto") {
1450
2126
  unit: null,
1451
2127
  alternates: [],
1452
2128
  alternateStyle: null,
2129
+ alternateIntro: null,
2130
+ capacity: null,
1453
2131
  item: text2,
1454
2132
  articleWord: null,
1455
2133
  countMultiplier: null
@@ -1461,7 +2139,10 @@ function parseIngredient(line, choice = "auto") {
1461
2139
  const article = range ? null : readArticle(stated, language);
1462
2140
  const quantity = range ?? parseLeadingQuantity(stated, language) ?? article;
1463
2141
  if (!quantity) return empty(null);
1464
- if (new RegExp("^-\\p{L}", "u").test(stated.slice(quantity.length))) return empty("sizeQualifier");
2142
+ const behindFigure = stated.slice(quantity.length);
2143
+ if (new RegExp("^-\\p{L}", "u").test(behindFigure)) return empty("sizeQualifier");
2144
+ if (quantity !== article && ORDINAL_SUFFIX.test(behindFigure)) return empty(null);
2145
+ if (TIME_UNIT[language].test(behindFigure.trimStart())) return empty("duration");
1465
2146
  let rest = stated.slice(quantity.length).trimStart();
1466
2147
  if (language === "en") rest = rest.replace(/^(?:of\s+)?an?\s+/i, "");
1467
2148
  const multiplier = readCountMultiplier(rest);
@@ -1473,26 +2154,44 @@ function parseIngredient(line, choice = "auto") {
1473
2154
  const behind = described.adjective ? takeLeadingUnit(described.rest, language, fromArticle) : null;
1474
2155
  const leading = direct.unit ? direct : behind?.unit ? behind : direct;
1475
2156
  rest = leading.rest;
1476
- const bracketed = takeAlternates(rest, language);
1477
- rest = bracketed.rest;
2157
+ const compound = range ? null : takeCompoundMember(rest, leading.unit, language);
2158
+ if (compound) rest = compound.rest;
2159
+ const whole = quantity.amount + (compound?.adds ?? 0);
2160
+ const introducedBracket = /^(?:de\s+|du\s+|des\s+|d'|of\s+)(?=\()/i.exec(rest);
2161
+ const bracketed = takeAlternates(
2162
+ introducedBracket ? rest.slice(introducedBracket[0].length) : rest,
2163
+ language
2164
+ );
2165
+ rest = bracketed.measures.length > 0 ? bracketed.rest : rest;
1478
2166
  const slashed = bracketed.measures.length > 0 ? null : takeSlashAlternates(rest, language);
1479
2167
  if (slashed) rest = slashed.rest;
1480
- const item = stripItemLead(rest, language);
2168
+ let item = stripItemLead(rest, language);
2169
+ const trailing = bracketed.measures.length === 0 && !slashed ? takeTrailingAlternates(item, language) : null;
2170
+ if (trailing) item = trailing.item;
2171
+ const group = trailing ?? (bracketed.measures.length > 0 ? bracketed : null);
2172
+ const capacity = group !== null && group.measures.every((measure) => measure.unit?.kind === "measured") && (namesContainer(leading.unit?.canonical) || namesContainer(item)) ? group.published : null;
1481
2173
  if (language === "fr" && !leading.unit && statesItemSize(item)) return empty("itemSize");
1482
2174
  if (language === "fr" && leading.partitive && isStatedSize(item)) return empty("itemSize");
2175
+ if (leading.unit?.kind === "measured" && CONTAINER_NOUN.test(normalizeUnitKey(item))) {
2176
+ return empty("containerSize");
2177
+ }
2178
+ const alternates = capacity !== null ? [] : slashed?.measures ?? group?.measures ?? [];
1483
2179
  return {
1484
2180
  original,
1485
2181
  language,
1486
2182
  heldBack: PER_PERSON[language].test(text2) ? "perPerson" : null,
2183
+ decoration,
1487
2184
  approximation: loose ? loose[0] : null,
1488
2185
  measureAdjective: behind?.unit ? described.adjective : null,
1489
- amount: quantity.amount * times,
2186
+ amount: whole * times,
1490
2187
  amountMax: range === null ? null : range.max * times,
1491
2188
  rangeSeparator: range?.separator ?? null,
1492
2189
  unit: leading.unit,
1493
- alternates: slashed ? slashed.measures : bracketed.measures,
1494
- alternateStyle: slashed ? "slash" : bracketed.measures.length > 0 ? "bracket" : null,
1495
- item: stripItemLead(rest, language),
2190
+ alternates,
2191
+ alternateStyle: alternates.length === 0 ? null : slashed ? "slash" : trailing ? "trailing" : "bracket",
2192
+ alternateIntro: capacity !== null ? null : group?.intro ?? null,
2193
+ capacity,
2194
+ item,
1496
2195
  articleWord: fromArticle ? article?.word ?? null : null,
1497
2196
  countMultiplier: multiplier?.times ?? null
1498
2197
  };
@@ -1508,7 +2207,7 @@ function unreadableComma(text2, language, evidence) {
1508
2207
  function readArticle(text2, language) {
1509
2208
  const counts = (rest) => takeLeadingUnit(rest, language, true).unit !== null || readCountMultiplier(rest) !== null;
1510
2209
  if (language === "fr") {
1511
- const match = /^\s*(un|une|quelques)\b\s*/i.exec(text2);
2210
+ const match = /^\s*(un|une)\b\s*/i.exec(text2);
1512
2211
  if (!match) return null;
1513
2212
  if (!counts(text2.slice(match[0].length))) return null;
1514
2213
  const word = match[1];
@@ -1548,18 +2247,35 @@ function stripItemLead(text2, language) {
1548
2247
  return text2.replace(/^of\s+/i, "").replace(/^an?\s+/i, "").trim();
1549
2248
  }
1550
2249
  function takeAlternates(text2, language) {
1551
- if (!text2.startsWith("(")) return { measures: [], rest: text2 };
2250
+ const none = { measures: [], intro: null, published: "", rest: text2 };
2251
+ if (!text2.startsWith("(")) return none;
1552
2252
  const close = text2.indexOf(")");
1553
- if (close < 0) return { measures: [], rest: text2 };
1554
- const inside = text2.slice(1, close);
1555
- const parts = inside.split("/").map((part) => part.trim());
2253
+ if (close < 0) return none;
2254
+ const read = readBracket(text2.slice(0, close + 1), language);
2255
+ if (!read) return none;
2256
+ return { ...read, rest: text2.slice(close + 1).trimStart() };
2257
+ }
2258
+ var EQUIVALENT_INTRODUCER = /^(?:soit|environ|about|approx\.?|approximately|or|ou|=)\s*/i;
2259
+ var MEASURE_PARTITIVE = /^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+|of\s+)/i;
2260
+ function takeMeasureAfterQuantity(text2, language) {
2261
+ const direct = takeUnitEitherLanguage(text2, language);
2262
+ if (direct.unit) return direct;
2263
+ const partitive = MEASURE_PARTITIVE.exec(text2);
2264
+ if (!partitive) return direct;
2265
+ return takeUnitEitherLanguage(text2.slice(partitive[0].length), language);
2266
+ }
2267
+ function readBracket(bracket, language) {
2268
+ const inside = bracket.slice(1, -1);
2269
+ const intro = EQUIVALENT_INTRODUCER.exec(inside);
2270
+ const body = intro ? inside.slice(intro[0].length) : inside;
2271
+ const parts = body.split(/(?<!\d)\/|\/(?!\d)/).map((part) => part.trim());
1556
2272
  const measures = [];
1557
2273
  for (const part of parts) {
1558
2274
  const range = parseLeadingRange(part, language);
1559
2275
  const quantity = range ?? parseLeadingQuantity(part, language);
1560
- if (!quantity) return { measures: [], rest: text2 };
1561
- const after = takeUnitEitherLanguage(part.slice(quantity.length).trimStart(), language);
1562
- if (!after.unit || after.rest.trim() !== "") return { measures: [], rest: text2 };
2276
+ if (!quantity) return null;
2277
+ const after = takeMeasureAfterQuantity(part.slice(quantity.length).trimStart(), language);
2278
+ if (!after.unit || after.rest.trim() !== "") return null;
1563
2279
  measures.push({
1564
2280
  amount: quantity.amount,
1565
2281
  amountMax: range?.max ?? null,
@@ -1567,8 +2283,17 @@ function takeAlternates(text2, language) {
1567
2283
  unit: after.unit
1568
2284
  });
1569
2285
  }
1570
- if (measures.length === 0) return { measures: [], rest: text2 };
1571
- return { measures, rest: text2.slice(close + 1).trimStart() };
2286
+ if (measures.length === 0) return null;
2287
+ return { measures, intro: intro ? intro[0].trim() : null, published: bracket };
2288
+ }
2289
+ function takeTrailingAlternates(item, language) {
2290
+ const closing = /\s*(\([^()]*\))\s*$/.exec(item);
2291
+ if (!closing) return null;
2292
+ const read = readBracket(closing[1], language);
2293
+ if (!read) return null;
2294
+ const head = item.slice(0, closing.index).trim();
2295
+ if (head === "") return null;
2296
+ return { ...read, item: head };
1572
2297
  }
1573
2298
  function takeSlashAlternates(text2, language) {
1574
2299
  const measures = [];
@@ -1619,6 +2344,7 @@ function roundTo(value, step) {
1619
2344
  return Math.round(Math.round(value / step) * step * 100) / 100;
1620
2345
  }
1621
2346
  function roundMeasured(value) {
2347
+ if (Number.isInteger(value)) return value;
1622
2348
  if (value >= 100) return roundTo(value, 5);
1623
2349
  if (value >= 10) return roundTo(value, 1);
1624
2350
  if (value >= 1) return roundTo(value, 0.1);
@@ -1692,6 +2418,10 @@ function landedExactly(exact, amount) {
1692
2418
  if (gap > EXACT_WITHIN) return false;
1693
2419
  return exact === 0 || gap / Math.abs(exact) <= EXACT_SHARE;
1694
2420
  }
2421
+ function boundLandedExactly(bound) {
2422
+ const fine = bound.ratio >= 1 ? bound.ratio : 1;
2423
+ return landedExactly(bound.raw * fine, bound.amount * (fine / bound.ratio));
2424
+ }
1695
2425
  function scaleMeasure(low, high, unit, factor, divisibility) {
1696
2426
  const raws = high === null ? [low * factor] : [low * factor, high * factor];
1697
2427
  const sources = high === null ? [low] : [low, high];
@@ -1707,7 +2437,8 @@ function scaleMeasure(low, high, unit, factor, divisibility) {
1707
2437
  amount: Math.min(usable, ceiling),
1708
2438
  exact,
1709
2439
  clamped: false,
1710
- raw
2440
+ raw,
2441
+ ratio
1711
2442
  };
1712
2443
  }),
1713
2444
  unit: target
@@ -1722,14 +2453,14 @@ function scaleMeasure(low, high, unit, factor, divisibility) {
1722
2453
  const bounds2 = raws.map((raw, index) => {
1723
2454
  const ceiling = factor < 1 ? sources[index] : Number.POSITIVE_INFINITY;
1724
2455
  const rounded = roundSpoon(raw, ceiling);
1725
- return { amount: rounded.value, exact: raw, clamped: rounded.clamped, raw };
2456
+ return { amount: rounded.value, exact: raw, clamped: rounded.clamped, raw, ratio: 1 };
1726
2457
  });
1727
2458
  return { bounds: bounds2, unit };
1728
2459
  }
1729
2460
  const bounds = raws.map((raw, index) => {
1730
2461
  const ceiling = factor < 1 ? sources[index] : Number.POSITIVE_INFINITY;
1731
2462
  const rounded = roundCountable(raw, divisibility, ceiling);
1732
- return { amount: rounded.value, exact: raw, clamped: rounded.clamped, raw };
2463
+ return { amount: rounded.value, exact: raw, clamped: rounded.clamped, raw, ratio: 1 };
1733
2464
  });
1734
2465
  return { bounds, unit };
1735
2466
  }
@@ -1858,6 +2589,8 @@ var IRREGULAR_SINGULAR = Object.fromEntries(
1858
2589
  Object.entries(IRREGULAR_PLURAL).map(([one, many]) => [many, one])
1859
2590
  );
1860
2591
  function matchCase(source, replacement) {
2592
+ const hasLetter = new RegExp("\\p{L}", "u").test(source);
2593
+ if (hasLetter && source === source.toUpperCase()) return replacement.toUpperCase();
1861
2594
  if (source[0] === source[0]?.toUpperCase() && source.slice(1) === source.slice(1).toLowerCase()) {
1862
2595
  return replacement[0].toUpperCase() + replacement.slice(1);
1863
2596
  }
@@ -1906,6 +2639,15 @@ function agreeInEnglish(item, amount) {
1906
2639
  else return item;
1907
2640
  return `${words.join(" ")}${tail}`;
1908
2641
  }
2642
+ var FRENCH_OU_PLURAL_IN_X = /* @__PURE__ */ new Set([
2643
+ "bijou",
2644
+ "caillou",
2645
+ "chou",
2646
+ "genou",
2647
+ "hibou",
2648
+ "joujou",
2649
+ "pou"
2650
+ ]);
1909
2651
  var INVARIABLE_FRENCH_NOUN = /* @__PURE__ */ new Set([
1910
2652
  "ananas",
1911
2653
  "anis",
@@ -1966,14 +2708,14 @@ var AGREEABLE_ADJECTIVES = /* @__PURE__ */ new Set([
1966
2708
  "emince",
1967
2709
  "emincee"
1968
2710
  ]);
1969
- function foldWord(word) {
2711
+ function foldWord2(word) {
1970
2712
  return word.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
1971
2713
  }
1972
2714
  function agreeTrailingAdjective(word, wantsPlural) {
1973
- const folded = foldWord(word);
2715
+ const folded = foldWord2(word);
1974
2716
  const isPlural = folded.endsWith("s");
1975
2717
  const singular = isPlural ? word.slice(0, -1) : word;
1976
- if (!AGREEABLE_ADJECTIVES.has(foldWord(singular))) return null;
2718
+ if (!AGREEABLE_ADJECTIVES.has(foldWord2(singular))) return null;
1977
2719
  if (wantsPlural === isPlural) return null;
1978
2720
  return wantsPlural ? `${word}s` : singular;
1979
2721
  }
@@ -1983,16 +2725,19 @@ function agreeInFrench(item, amount) {
1983
2725
  const head = words[0] ?? "";
1984
2726
  if (head.length <= 3) return item;
1985
2727
  const wantsPlural = amount >= 2;
1986
- const isPlural = /s$|eaux$|aux$/i.test(head);
2728
+ const ouPlural = /x$/i.test(head) && FRENCH_OU_PLURAL_IN_X.has(foldWord2(head.slice(0, -1)));
2729
+ const isPlural = /s$|eaux$|aux$/i.test(head) || ouPlural;
1987
2730
  if (wantsPlural && !isPlural) {
1988
2731
  if (/[sxz]$/i.test(head)) {
1989
2732
  } else if (/eau$/i.test(head)) words[0] = `${head}x`;
2733
+ else if (FRENCH_OU_PLURAL_IN_X.has(foldWord2(head))) words[0] = `${head}x`;
1990
2734
  else if (/al$/i.test(head)) words[0] = `${head.slice(0, -2)}aux`;
1991
2735
  else words[0] = `${head}s`;
1992
2736
  } else if (!wantsPlural && isPlural) {
1993
2737
  if (/eaux$/i.test(head)) words[0] = head.slice(0, -1);
1994
2738
  else if (/aux$/i.test(head)) words[0] = `${head.slice(0, -3)}al`;
1995
- else if (INVARIABLE_FRENCH_NOUN.has(foldWord(head))) {
2739
+ else if (ouPlural) words[0] = head.slice(0, -1);
2740
+ else if (INVARIABLE_FRENCH_NOUN.has(foldWord2(head))) {
1996
2741
  } else words[0] = head.slice(0, -1);
1997
2742
  }
1998
2743
  const last = words.length - 1;
@@ -2005,16 +2750,22 @@ function agreeInFrench(item, amount) {
2005
2750
  function agreeLeadingAdjective(word, amount, language) {
2006
2751
  if (language === "en") return word;
2007
2752
  const wantsPlural = amount >= 2;
2008
- const folded = foldWord(word);
2753
+ const folded = foldWord2(word);
2009
2754
  const isPlural = folded.endsWith("s") && !AGREEABLE_ADJECTIVES.has(folded);
2010
2755
  const singular = isPlural ? word.slice(0, -1) : word;
2011
- if (!AGREEABLE_ADJECTIVES.has(foldWord(singular))) return word;
2756
+ if (!AGREEABLE_ADJECTIVES.has(foldWord2(singular))) return word;
2012
2757
  if (wantsPlural === isPlural) return word;
2013
2758
  return wantsPlural ? `${singular}s` : singular;
2014
2759
  }
2015
2760
  function agreeWithAmount(item, amount, language) {
2016
2761
  return language === "fr" ? agreeInFrench(item, amount) : agreeInEnglish(item, amount);
2017
2762
  }
2763
+ function agreeCountedContainer(item, amount, language) {
2764
+ if (language === "fr") return agreeInFrench(item, amount);
2765
+ const space = item.indexOf(" ");
2766
+ if (space < 0) return agreeInEnglish(item, amount);
2767
+ return `${agreeInEnglish(item.slice(0, space), amount)}${item.slice(space)}`;
2768
+ }
2018
2769
  var MUTE_H_WORDS = /^(?:huile|huiles|huitre|huitres|huître|huîtres|herbe|herbes|hysope)\b/i;
2019
2770
  function joinItem(item, language) {
2020
2771
  if (!item) return "";
@@ -2073,7 +2824,9 @@ function scaleAlternative(tail, options) {
2073
2824
  var HELD_BACK_NOTE = {
2074
2825
  sizeQualifier: "The figures here give the size of one item rather than how many, so the line is left as published.",
2075
2826
  itemSize: "The measure standing behind the item gives the size of one of them rather than how many, so the line is left as published. Serving more people is a matter of taking a bigger one, and serving fewer a smaller one.",
2827
+ containerSize: "The measure here gives what one container holds rather than how many of them the recipe uses, and the line states no count, so it is left as published. Scaling it is a matter of opening that many containers of the size named.",
2076
2828
  perPerson: "This line already states an amount for one person, and the factor is what changes how many people the recipe serves, so the line is left as published.",
2829
+ duration: "This line states a length of time rather than an amount of an ingredient, so it is left as published. A rest, a proof or a bake takes as long for a large batch as for a small one, and the factor has nothing to say about it.",
2077
2830
  ambiguousDecimal: "The comma in this number marks thousands in one convention and the decimal point in another, and the line gives no sign which was meant, so it is left as published."
2078
2831
  };
2079
2832
  function scaleSingleLine(line, options) {
@@ -2097,10 +2850,12 @@ function scaleSingleLine(line, options) {
2097
2850
  const alternates = parsed.alternates.map((measure) => renderMeasure(measure, factor, language));
2098
2851
  const primaryBounds = primary.bounds;
2099
2852
  const alternateBounds = alternates.flatMap((entry) => entry.bounds);
2100
- const movedPrimary = primaryBounds.some((b) => !landedExactly(b.exact, b.amount));
2101
- const movedAlternate = alternateBounds.some((b) => !landedExactly(b.exact, b.amount));
2853
+ const movedPrimary = primaryBounds.some((bound) => !boundLandedExactly(bound));
2854
+ const movedAlternate = alternateBounds.some((bound) => !boundLandedExactly(bound));
2102
2855
  const clamped = [...primaryBounds, ...alternateBounds].find((bound) => bound.clamped) ?? null;
2103
2856
  const restated = parsed.alternateStyle === "slash";
2857
+ const carriesMore = hasEmbeddedMeasure(parsed.item, language);
2858
+ const leftover = carriesMore && parsed.unit?.kind === "measured";
2104
2859
  const low = primaryBounds[0];
2105
2860
  const high = primaryBounds[1] ?? null;
2106
2861
  const unit = primary.unit;
@@ -2116,13 +2871,17 @@ function scaleSingleLine(line, options) {
2116
2871
  const adjective = named && parsed.measureAdjective ? ` ${agreeLeadingAdjective(parsed.measureAdjective, shown, language)}` : "";
2117
2872
  const unitLabel = named ? `${adjective} ${formatUnit(named, shown, language)}` : "";
2118
2873
  const alternateTexts = alternates.map((entry) => entry.text);
2119
- const altLabel = alternates.length === 0 ? "" : parsed.alternateStyle === "slash" ? ` / ${alternateTexts.join(" / ")}` : ` (${alternateTexts.join(" / ")})`;
2120
- const counted = agreeWithAmount(parsed.item, shown, language);
2874
+ const introduced = parsed.alternateIntro ? `${parsed.alternateIntro} ` : "";
2875
+ const bracketed = alternates.length === 0 ? "" : ` (${introduced}${alternateTexts.join(" / ")})`;
2876
+ const altLabel = alternates.length === 0 ? "" : parsed.alternateStyle === "slash" ? ` / ${alternateTexts.join(" / ")}` : parsed.alternateStyle === "trailing" ? "" : bracketed;
2877
+ const trailingLabel = parsed.alternateStyle === "trailing" ? bracketed : "";
2878
+ const capacityLabel = parsed.capacity ? ` ${parsed.capacity}` : "";
2879
+ const counted = parsed.capacity ? agreeCountedContainer(parsed.item, shown, language) : agreeWithAmount(parsed.item, shown, language);
2121
2880
  const itemLabel = named ? joinItem(parsed.item, language) : counted ? ` ${counted}` : "";
2122
2881
  const result = {
2123
- text: `${parsed.approximation ?? ""}${amountText}${unitLabel}${altLabel}${itemLabel}`.trim(),
2882
+ text: `${parsed.decoration ? `${parsed.decoration} ` : ""}${parsed.approximation ?? ""}${amountText}${unitLabel}${capacityLabel}${altLabel}${itemLabel}${trailingLabel}`.trim(),
2124
2883
  original: parsed.original,
2125
- scaling: movedPrimary || movedAlternate || restated ? "rounded" : "scaled",
2884
+ scaling: movedPrimary || movedAlternate || restated || leftover ? "rounded" : "scaled",
2126
2885
  amount: low.amount,
2127
2886
  amountMax: collapsed ? null : high?.amount ?? null,
2128
2887
  unit: named?.canonical ?? null,
@@ -2135,7 +2894,7 @@ function scaleSingleLine(line, options) {
2135
2894
  `Clamped up to ${formatAmount(clamped.amount, language)} from ${formatAmount(Math.round(clamped.raw * 1e3) / 1e3, language)}, the smallest amount worth measuring. This line no longer holds its share of the recipe.`
2136
2895
  );
2137
2896
  } else if (movedPrimary) {
2138
- const moved = primaryBounds.filter((bound) => !landedExactly(bound.exact, bound.amount));
2897
+ const moved = primaryBounds.filter((bound) => !boundLandedExactly(bound));
2139
2898
  sentences.push(
2140
2899
  moved.map(
2141
2900
  (bound) => `Rounded ${bound.amount > bound.exact ? "up" : "down"} from ${asPublished(bound.raw, parsed.unit)}.`
@@ -2150,11 +2909,16 @@ function scaleSingleLine(line, options) {
2150
2909
  "This line states one quantity twice, and both readings were scaled. They agree as closely as the page wrote them, and no closer."
2151
2910
  );
2152
2911
  }
2153
- if (hasEmbeddedMeasure(parsed.item, language)) {
2912
+ if (carriesMore) {
2154
2913
  sentences.push(
2155
2914
  "This line carries a further quantity after the first one, and only the first was scaled. Read the rest as published."
2156
2915
  );
2157
2916
  }
2917
+ if (parsed.capacity) {
2918
+ sentences.push(
2919
+ `${parsed.capacity} is what one container holds, so it stays as published and the count is what the factor moved. Scaling this line is a matter of opening that many containers of the size named.`
2920
+ );
2921
+ }
2158
2922
  if (collapsed) {
2159
2923
  sentences.push("The page gave a range, and at this size both ends come to the same amount.");
2160
2924
  }
@@ -2236,6 +3000,16 @@ function passthroughIngredients(lines, language = "auto") {
2236
3000
  return lines.map((line) => passthroughIngredient(line, language));
2237
3001
  }
2238
3002
 
3003
+ // src/recipe/sections.ts
3004
+ var HEADINGS = {
3005
+ ingredients: /^\s*(?:ingr[ée]dients?|ingredients?|garniture)\b/i,
3006
+ method: /^\s*(?:procedure|proc[ée]d[ée]|directions?|instructions?|method|preparation|pr[ée]paration|r[ée]alisation|steps?|[ée]tapes?)\b/i
3007
+ };
3008
+ function headingFor(part, headings) {
3009
+ if (headings === null) return null;
3010
+ return headings.find((heading) => HEADINGS[part].test(heading)) ?? null;
3011
+ }
3012
+
2239
3013
  // src/tools/shared.ts
2240
3014
  import { z } from "zod";
2241
3015
  var MAX_TEXT_CHARS = 2200;
@@ -2259,6 +3033,22 @@ var reportSchema = z.object({
2259
3033
  ),
2260
3034
  skipped: z.number().int().describe("Rows this source sent in a shape the server could not read, and left out."),
2261
3035
  cached: z.boolean().describe("Served from this server's short-lived in-memory cache."),
3036
+ preferred_by_name: z.boolean().describe(
3037
+ "Whether this source's rows were arranged so the ones whose title names the dish come first. An order over one source's own rows, never a score against another source."
3038
+ ),
3039
+ wordings: z.array(
3040
+ z.object({
3041
+ query: z.string().describe("What was sent to this source, or would have been."),
3042
+ derivation: z.string().describe("How this wording was derived from the question."),
3043
+ ran: z.boolean(),
3044
+ count: z.number().int().nullable().describe("Rows this wording returned. Null when it did not run."),
3045
+ added: z.number().int().nullable().describe("Rows it returned that no earlier wording had."),
3046
+ not_run_because: z.string().nullable().describe("Why it was held back. Null when it ran."),
3047
+ error: z.object({ code: z.string(), message: z.string(), hint: z.string().optional() }).nullable()
3048
+ })
3049
+ ).describe(
3050
+ "Every wording this source was sent or held back from, in the order tried. A question written as a sentence is also sent in wordings derived from it, because these indexes answer the words they are handed. Counts here belong to one wording each and are never added."
3051
+ ),
2262
3052
  error: z.object({
2263
3053
  code: z.string(),
2264
3054
  message: z.string(),
@@ -2319,6 +3109,16 @@ function toReportPayload(report) {
2319
3109
  reported_total_means: report.reportedTotalMeans,
2320
3110
  skipped: report.skipped,
2321
3111
  cached: report.cached,
3112
+ preferred_by_name: report.preferredByName,
3113
+ wordings: report.wordings.map((attempt) => ({
3114
+ query: attempt.query,
3115
+ derivation: attempt.derivation,
3116
+ ran: attempt.ran,
3117
+ count: attempt.count,
3118
+ added: attempt.added,
3119
+ not_run_because: attempt.notRunBecause,
3120
+ error: attempt.error
3121
+ })),
2322
3122
  error: report.error ?? null,
2323
3123
  // A search alone opens nothing, so only a tool that reads a row fills this
2324
3124
  // in, through `withRead`.
@@ -2328,6 +3128,18 @@ function toReportPayload(report) {
2328
3128
  function withRead(payload, read) {
2329
3129
  return { ...payload, read };
2330
3130
  }
3131
+ function mustKeep(text2) {
3132
+ return { text: text2, essential: true };
3133
+ }
3134
+ function noteText(note) {
3135
+ return typeof note === "string" ? note : note.text;
3136
+ }
3137
+ function noteTexts(notes) {
3138
+ return notes.map(noteText);
3139
+ }
3140
+ function labelNote(label, note) {
3141
+ return typeof note === "string" ? `${label}: ${note}` : mustKeep(`${label}: ${note.text}`);
3142
+ }
2331
3143
  function reportNotes(reports) {
2332
3144
  const notes = [];
2333
3145
  const answered = reports.filter((report) => report.status === "answered");
@@ -2335,22 +3147,42 @@ function reportNotes(reports) {
2335
3147
  for (const report of failed) {
2336
3148
  const consolation = answered.length > 0 ? `This answer holds what the other sources found, and says nothing about what ${report.name} holds.` : "Nothing here is evidence about what it holds.";
2337
3149
  notes.push(
2338
- `${report.name} did not answer (${report.error?.code}): ${report.error?.message} ${consolation}`
3150
+ mustKeep(
3151
+ `${report.name} did not answer (${report.error?.code}): ${report.error?.message} ${consolation}`
3152
+ )
2339
3153
  );
2340
3154
  }
2341
3155
  for (const report of answered) {
3156
+ const sent = report.wordings.filter((attempt) => attempt.ran && attempt.error === null);
2342
3157
  if (report.count === 0) {
2343
3158
  notes.push(
2344
- `${report.name} answered and offered no row for this wording. That is a statement about the wording as much as about the corpus: try the dish's name in another language, or a main ingredient.`
3159
+ mustKeep(
3160
+ sent.length > 1 ? `${report.name} answered and offered no row for any of the ${sent.length} wordings it was sent (${sent.map((attempt) => `"${attempt.query}"`).join(", ")}). Each of those is a statement about a wording; together they are the closest this server comes to saying the corpus holds nothing. Try a main ingredient, or the dish's name in the language that source publishes in.` : `${report.name} answered and offered no row for this wording. That is a statement about the wording as much as about the corpus: try the dish's name in another language, or a main ingredient.`
3161
+ )
3162
+ );
3163
+ }
3164
+ if (sent.length > 1) {
3165
+ notes.push(
3166
+ `${report.name} was asked ${sent.length} wordings and these rows are their union: ${sent.map((attempt) => `"${attempt.query}" (${attempt.count ?? 0})`).join(", ")}. Those counts are per wording and are never added.`
3167
+ );
3168
+ }
3169
+ if (report.preferredByName) {
3170
+ notes.push(
3171
+ `${report.name}'s rows are arranged with the ones naming the dish first, so a wording that returned near-misses cannot crowd out what another wording found. That is an order over one source's own rows and not a score against any other source.`
2345
3172
  );
2346
3173
  }
2347
3174
  if (report.skipped > 0) {
2348
3175
  notes.push(
2349
- `${report.name} sent ${report.skipped} row(s) in a shape this server could not read, and they were left out.`
3176
+ mustKeep(
3177
+ `${report.name} sent ${report.skipped} row(s) in a shape this server could not read, and they were left out.`
3178
+ )
2350
3179
  );
2351
3180
  }
2352
3181
  if (report.reportedTotal !== null && report.reportedTotalMeans !== null) {
2353
- notes.push(`${report.name} reported ${report.reportedTotal}: ${report.reportedTotalMeans}.`);
3182
+ const forWording = sent.length > 1 ? ` That number belongs to "${sent[0].query}" alone.` : "";
3183
+ notes.push(
3184
+ `${report.name} reported ${report.reportedTotal}: ${report.reportedTotalMeans}.${forWording}`
3185
+ );
2354
3186
  } else {
2355
3187
  notes.push(
2356
3188
  `${report.name} states no total and offers no second page, so a short list here is not evidence that little exists.`
@@ -2360,7 +3192,7 @@ function reportNotes(reports) {
2360
3192
  if (answered.some((report) => report.mixesReferencePages && report.count > 0)) {
2361
3193
  const mixing = answered.filter((report) => report.mixesReferencePages && report.count > 0);
2362
3194
  notes.push(
2363
- `${mixing.map((report) => report.name).join(" and ")} files pages about an ingredient beside recipes using it, so a row here can be a page about an ingredient rather than a recipe. get_recipe says when a page carries no ingredient list.`
3195
+ `${mixing.map((report) => report.name).join(" and ")} files pages about an ingredient beside recipes using it, so a row here can be a page about an ingredient rather than a recipe. get_recipe says what it read off the page.`
2364
3196
  );
2365
3197
  }
2366
3198
  if (answered.length > 1) {
@@ -2377,22 +3209,30 @@ var SERVER_MARKERS = /^(\s*)(Note|Sources?|Hint|Ingredients|Steps|What differs)(
2377
3209
  function quoteForeign(value) {
2378
3210
  return value.replace(/[\r\n\u2028\u2029\u0085]+/g, " ").replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "").replace(/!\[/g, "!\\[").replace(/\s{2,}/g, " ").trim().replace(SERVER_MARKERS, " $1$2$3");
2379
3211
  }
2380
- var LOAD_BEARING = /did not answer|could not be read|no longer holds its share|Published under|licen[cs]e|https?:\/\/|rather than a comparison|states no number of servings|no ingredient list|offered no row/i;
2381
3212
  var NOTE_BUDGET = Math.round(MAX_TEXT_CHARS * 0.55);
2382
3213
  function buildTrailer(options) {
2383
- const credit = options.credit ?? "Source: this server called none.";
2384
- const kept = [...new Set(options.notes ?? [])];
2385
- while (kept.length > 0 && kept.join("\n").length > NOTE_BUDGET) {
2386
- let victim = kept.length - 1;
3214
+ const kept = [];
3215
+ const held = /* @__PURE__ */ new Set();
3216
+ for (const note of options.notes ?? []) {
3217
+ const text2 = noteText(note);
3218
+ if (held.has(text2)) continue;
3219
+ held.add(text2);
3220
+ kept.push(note);
3221
+ }
3222
+ const length = () => kept.map(noteText).join("\n").length;
3223
+ while (kept.length > 0 && length() > NOTE_BUDGET) {
3224
+ let victim = -1;
2387
3225
  for (let index = kept.length - 1; index >= 0; index -= 1) {
2388
- if (!LOAD_BEARING.test(kept[index])) {
3226
+ if (typeof kept[index] === "string") {
2389
3227
  victim = index;
2390
3228
  break;
2391
3229
  }
2392
3230
  }
3231
+ if (victim === -1) break;
2393
3232
  kept.splice(victim, 1);
2394
3233
  }
2395
- return [...kept.map((note) => `Note: ${note}`), credit].join("\n");
3234
+ const credit = options.credit ?? "Source: this server called none.";
3235
+ return [...kept.map((note) => `Note: ${noteText(note)}`), credit].join("\n");
2396
3236
  }
2397
3237
  function ok(structured, body, options = {}) {
2398
3238
  const trailer = buildTrailer(options);
@@ -2416,6 +3256,20 @@ function toToolError(error) {
2416
3256
  function roomForBody(options = {}) {
2417
3257
  return Math.max(200, MAX_TEXT_CHARS - buildTrailer(options).length - 60);
2418
3258
  }
3259
+ var ALWAYS_SHOWN = 3;
3260
+ function fitLines(lines, room) {
3261
+ const kept = [];
3262
+ let used = 0;
3263
+ for (const line of lines) {
3264
+ if (kept.length >= ALWAYS_SHOWN && used + line.length > room) break;
3265
+ kept.push(line);
3266
+ used += line.length + 1;
3267
+ }
3268
+ return { lines: kept, hidden: lines.length - kept.length };
3269
+ }
3270
+ function omittedLinesLine(shown, total, what) {
3271
+ return `\u2026 ${shown} of ${total} ${what} shown here; all ${total} are in the structured output`;
3272
+ }
2419
3273
  function truncate(text2, maxChars) {
2420
3274
  if (text2.length <= maxChars) return text2;
2421
3275
  return `${text2.slice(0, Math.max(0, maxChars - 1)).trimEnd()}\u2026`;
@@ -2497,7 +3351,9 @@ function resolveFactor(recipe, servings) {
2497
3351
  return {
2498
3352
  factor: null,
2499
3353
  notes: [
2500
- `${recipe.sourceName} states no number of servings for this recipe, so the quantities are as published. Use scale_ingredients with a factor you choose if you know what it serves.`
3354
+ mustKeep(
3355
+ `${recipe.sourceName} states no number of servings for this recipe, so the quantities are as published. Use scale_ingredients with a factor you choose if you know what it serves.`
3356
+ )
2501
3357
  ]
2502
3358
  };
2503
3359
  }
@@ -2525,15 +3381,45 @@ function resolveFactor(recipe, servings) {
2525
3381
  function round(value) {
2526
3382
  return Math.round(value * 1e3) / 1e3;
2527
3383
  }
3384
+ var INGREDIENTS_PART = {
3385
+ part: "ingredients",
3386
+ entry: "ingredient line",
3387
+ whole: "ingredient list",
3388
+ caution: "An empty list here is never evidence that an ingredient is absent from the dish."
3389
+ };
3390
+ var METHOD_PART = {
3391
+ part: "method",
3392
+ entry: "step of method",
3393
+ whole: "method",
3394
+ caution: "An empty method here is never evidence that the dish is made without one."
3395
+ };
3396
+ function emptyPartNote(recipe, words) {
3397
+ const heading = headingFor(words.part, recipe.publishedSections);
3398
+ const counterpart = words.part === "ingredients" ? recipe.steps.length > 0 ? `${recipe.steps.length} step(s) of method were read from it` : null : recipe.ingredients.length > 0 ? `${recipe.ingredients.length} ingredient line(s) were read from it` : null;
3399
+ const shown = [
3400
+ heading === null ? null : `the page heads a section "${quoteForeign(heading)}"`,
3401
+ counterpart
3402
+ ].filter((clause) => clause !== null);
3403
+ if (shown.length > 0) {
3404
+ return mustKeep(
3405
+ `No ${words.entry} was read from this page, yet ${shown.join(" and ")}. This server failed to read the ${words.whole} the page carries; read it at the url. ${words.caution}`
3406
+ );
3407
+ }
3408
+ const looked = recipe.publishedSections === null ? "" : ", which heads no section announcing one";
3409
+ return mustKeep(
3410
+ `No ${words.entry} was read from this page${looked}. A page publishing no ${words.whole} and one written in a layout this server cannot follow look the same from here, so read the url before settling which this is. ${words.caution}`
3411
+ );
3412
+ }
2528
3413
  function buildRecipeView(recipe, options) {
2529
3414
  const { factor, notes: yieldNotes } = resolveFactor(recipe, options.servings);
2530
3415
  const wants = (section) => options.sections.includes(section);
2531
3416
  const ingredients = factor === null || factor === 1 ? passthroughIngredients(recipe.ingredients, recipe.language) : scaleIngredients(recipe.ingredients, { factor, language: recipe.language });
2532
3417
  const notes = [...yieldNotes];
2533
- if (recipe.ingredients.length === 0) {
2534
- notes.push(
2535
- `${recipe.sourceName} publishes no ingredient list on this page. It is likely a page about the dish rather than a recipe for it, so there is nothing here to scale. Search again and pick another row.`
2536
- );
3418
+ if (wants("ingredients") && recipe.ingredients.length === 0) {
3419
+ notes.push(emptyPartNote(recipe, INGREDIENTS_PART));
3420
+ }
3421
+ if (wants("steps") && recipe.steps.length === 0) {
3422
+ notes.push(emptyPartNote(recipe, METHOD_PART));
2537
3423
  }
2538
3424
  const rounded = ingredients.filter((entry) => entry.scaling === "rounded");
2539
3425
  const unscaled = ingredients.filter((entry) => entry.scaling === "unscaled");
@@ -2549,7 +3435,9 @@ function buildRecipeView(recipe, options) {
2549
3435
  }
2550
3436
  if (recipe.license) {
2551
3437
  notes.push(
2552
- `Published under ${quoteForeign(recipe.license.title)}: ${quoteForeign(recipe.license.url)}`
3438
+ mustKeep(
3439
+ `Published under ${quoteForeign(recipe.license.title)}: ${quoteForeign(recipe.license.url)}`
3440
+ )
2553
3441
  );
2554
3442
  }
2555
3443
  const omitted = SECTIONS.filter((section) => !wants(section));
@@ -2604,7 +3492,7 @@ function buildRecipeView(recipe, options) {
2604
3492
  };
2605
3493
  if (options.label) {
2606
3494
  const label = quoteForeign(options.label);
2607
- return { payload, notes: notes.map((line) => `${label}: ${line}`), ingredients };
3495
+ return { payload, notes: notes.map((line) => labelNote(label, line)), ingredients };
2608
3496
  }
2609
3497
  return { payload, notes, ingredients };
2610
3498
  }
@@ -2694,7 +3582,7 @@ function listNames(names2) {
2694
3582
  if (names2.length <= 1) return names2.join("");
2695
3583
  return `${names2.slice(0, -1).join(", ")} and ${names2[names2.length - 1]}`;
2696
3584
  }
2697
- function describeDifferences(recipes, payloads) {
3585
+ function describeDifferences(recipes, payloads, sections) {
2698
3586
  if (recipes.length < 2) return [];
2699
3587
  const differences = [];
2700
3588
  const nameOf = (recipe) => recipe.sourceName;
@@ -2712,17 +3600,19 @@ function describeDifferences(recipes, payloads) {
2712
3600
  ).join("; ")}.`
2713
3601
  );
2714
3602
  }
2715
- const timed = recipes.filter((recipe) => recipe.totalMinutes !== null);
2716
- const untimed = recipes.filter((recipe) => recipe.totalMinutes === null);
2717
- if (timed.length > 0 && new Set(timed.map((recipe) => recipe.totalMinutes)).size > 1) {
2718
- differences.push(
2719
- `${timed.map((recipe) => `${nameOf(recipe)} states ${recipe.totalMinutes} minutes`).join("; ")}.`
2720
- );
2721
- }
2722
- if (timed.length > 0 && untimed.length > 0) {
2723
- differences.push(
2724
- `${listNames(untimed.map(nameOf))} states no time for this recipe, so there is nothing there to compare against.`
2725
- );
3603
+ if (sections.includes("times")) {
3604
+ const timed = recipes.filter((recipe) => recipe.totalMinutes !== null);
3605
+ const untimed = recipes.filter((recipe) => recipe.totalMinutes === null);
3606
+ if (timed.length > 0 && new Set(timed.map((recipe) => recipe.totalMinutes)).size > 1) {
3607
+ differences.push(
3608
+ `${timed.map((recipe) => `${nameOf(recipe)} states ${recipe.totalMinutes} minutes`).join("; ")}.`
3609
+ );
3610
+ }
3611
+ if (timed.length > 0 && untimed.length > 0) {
3612
+ differences.push(
3613
+ `${listNames(untimed.map(nameOf))} states no time for this recipe, so there is nothing there to compare against.`
3614
+ );
3615
+ }
2726
3616
  }
2727
3617
  const rated = recipes.filter((recipe) => recipe.rating !== null);
2728
3618
  if (rated.length > 0 && rated.length < recipes.length) {
@@ -2801,28 +3691,63 @@ async function runCompareRecipes(client, args) {
2801
3691
  const failedRead = unread.get(report.source);
2802
3692
  if (report.status === "failed") {
2803
3693
  notes.push(
2804
- `${report.name} is missing from this comparison because its search did not answer (${quoteForeign(
2805
- `${report.error?.code ?? "unknown"}: ${report.error?.message ?? "unknown"}`
2806
- )}). Nothing here is evidence about what it holds.`
3694
+ mustKeep(
3695
+ `${report.name} is missing from this comparison because its search did not answer (${quoteForeign(
3696
+ `${report.error?.code ?? "unknown"}: ${report.error?.message ?? "unknown"}`
3697
+ )}). Nothing here is evidence about what it holds.`
3698
+ )
2807
3699
  );
2808
3700
  } else if (failedRead) {
2809
3701
  notes.push(
2810
- `${report.name}'s search answered and offered a version, and that version could not be read (${quoteForeign(
2811
- `${failedRead.code}: ${failedRead.message}`
2812
- )}). The failure is in the reading, so nothing here says whether ${report.name} holds this dish.`
3702
+ mustKeep(
3703
+ `${report.name}'s search answered and offered a version, and that version could not be read (${quoteForeign(
3704
+ `${failedRead.code}: ${failedRead.message}`
3705
+ )}). The failure is in the reading, so nothing here says whether ${report.name} holds this dish.`
3706
+ )
2813
3707
  );
2814
3708
  } else {
2815
3709
  notes.push(
2816
- `${report.name} answered and offered nothing close enough to "${quoteForeign(args.dish)}" to compare.`
3710
+ mustKeep(
3711
+ `${report.name} answered and offered nothing close enough to "${quoteForeign(args.dish)}" to compare.`
3712
+ )
2817
3713
  );
2818
3714
  }
2819
3715
  }
2820
- if (payloads.length === 1) {
3716
+ const shortfall = payloads.map((payload) => dishWordsMissing(payload.title, args.dish));
3717
+ const named = payloads.filter((_payload, index) => shortfall[index].length === 0);
3718
+ const carriesDish = named.length > 0;
3719
+ payloads.forEach((payload, index) => {
3720
+ const missing = shortfall[index];
3721
+ if (missing.length === 0) return;
3722
+ notes.push(
3723
+ mustKeep(
3724
+ `${payload.source_name}'s closest row is ${quoteForeign(payload.title)}, whose title carries no ${missing.map((word) => `"${quoteForeign(word)}"`).join(" and no ")}. Read it as a candidate to check rather than as that dish.`
3725
+ )
3726
+ );
3727
+ });
3728
+ if (payloads.length > 0 && !carriesDish) {
3729
+ notes.push(
3730
+ `No version here carries the whole of "${quoteForeign(args.dish)}" in its title. These are the closest rows each source returned for that spelling, so read them as candidates to check rather than as that dish.`
3731
+ );
3732
+ }
3733
+ if (payloads.length === 1 && carriesDish) {
2821
3734
  notes.push(
2822
- "This is one version rather than a comparison. Read it as what that one source publishes."
3735
+ mustKeep(
3736
+ "This is one version rather than a comparison. Read it as what that one source publishes."
3737
+ )
3738
+ );
3739
+ }
3740
+ const comparable = recipes.filter(
3741
+ (recipe) => named.some((payload) => payload.id === recipe.id && payload.source === recipe.source)
3742
+ );
3743
+ const differences = comparable.length === payloads.length ? describeDifferences(recipes, payloads, args.sections) : [];
3744
+ if (payloads.length > 1 && differences.length === 0 && comparable.length < payloads.length) {
3745
+ notes.push(
3746
+ mustKeep(
3747
+ "Nothing is set side by side here: not every version's title carries the dish that was asked for, so what separates them may be that they are different dishes."
3748
+ )
2823
3749
  );
2824
3750
  }
2825
- const differences = describeDifferences(recipes, payloads);
2826
3751
  if (payloads.length > 1) {
2827
3752
  notes.push(
2828
3753
  "Quantities are shown in the units each source published. Nothing was converted between measuring systems, so a gram in one version and a cup in another are two ways of writing a recipe rather than one quantity restated."
@@ -2843,20 +3768,20 @@ ${shownDifferences.map((line) => `- ${line}`).join("\n")}${moreDifferences > 0 ?
2843
3768
  const head = [
2844
3769
  `${quoteForeign(payload.source_name)}: ${quoteForeign(payload.title)}`,
2845
3770
  ` ${quoteForeign(payload.url)}`,
2846
- ` yields ${quoteForeign(payload.yield.original_text ?? "an unstated amount")}${payload.yield.factor === null ? "" : `, scaled by ${payload.yield.factor}`}`
3771
+ // The yield is the page's, and the list under it is the one the factor
3772
+ // produced. Stating the first alone above the second reads as a list
3773
+ // for that many people.
3774
+ ` ${renderYield(payload)}`
2847
3775
  ];
2848
- const lines = [...head];
2849
- let used = head.join("\n").length;
2850
- let shown = 0;
2851
- for (const entry of payload.ingredients.slice(0, TEXT_INGREDIENT_LINES)) {
2852
- const line = ` - ${quoteForeign(entry.text)}`;
2853
- if (used + line.length > share) break;
2854
- lines.push(line);
2855
- used += line.length + 1;
2856
- shown += 1;
3776
+ const offered2 = payload.ingredients.slice(0, TEXT_INGREDIENT_LINES).map((entry) => ` - ${quoteForeign(entry.text)}`);
3777
+ const fitted = fitLines(offered2, share - head.join("\n").length);
3778
+ const lines = [...head, ...fitted.lines];
3779
+ const hidden = payload.ingredients.length - fitted.lines.length;
3780
+ if (hidden > 0) {
3781
+ lines.push(
3782
+ ` ${omittedLinesLine(fitted.lines.length, payload.ingredients.length, "ingredient lines")}`
3783
+ );
2857
3784
  }
2858
- const hidden = payload.ingredients.length - shown;
2859
- if (hidden > 0) lines.push(` \u2026 and ${hidden} more, in full in the structured output`);
2860
3785
  return lines.join("\n");
2861
3786
  });
2862
3787
  const body = payloads.length === 0 ? unread.size > 0 ? `Every version of "${quoteForeign(args.dish)}" that was offered could not be read, so nothing was compared.` : `No source offered a recipe for "${quoteForeign(args.dish)}".` : versionBlocks.join("\n\n");
@@ -2877,7 +3802,7 @@ ${shownDifferences.map((line) => `- ${line}`).join("\n")}${moreDifferences > 0 ?
2877
3802
  failedRead ? { status: "failed", error: { code: failedRead.code, message: failedRead.message } } : { status: "read", error: null }
2878
3803
  );
2879
3804
  }),
2880
- notes
3805
+ notes: noteTexts(notes)
2881
3806
  },
2882
3807
  summary,
2883
3808
  { notes, credit }
@@ -2894,6 +3819,7 @@ var getRecipeDescription = [
2894
3819
  "'id' must come from search_recipes. It names the source, so this reads the right one without guessing; an identifier no source would have minted is refused, because sending it anywhere would answer about the wrong dish.",
2895
3820
  "Pass 'servings' to rescale. Quantities land where a kitchen can follow them: an egg stays whole, anything that pours or cuts can halve, a small measurement moves to a smaller unit before it is rounded, and anything unmultipliable is flagged rather than scaled.",
2896
3821
  "A page that states no number of servings comes back as published and says so, because dividing by a yield nobody wrote would answer for a number of people the page never claimed.",
3822
+ "A part this answer holds nothing for says which of two things happened: the page showed no sign of it, or this server failed to read what the page carries. An empty ingredient list is never evidence that an ingredient is absent from the dish.",
2897
3823
  "'sections' decides what comes back, and 'sections_omitted' names what was left out: a field belonging to an omitted section is empty because nobody asked for it, never because the page states nothing.",
2898
3824
  "A field a source does not publish is null, never zero. Credit the source and link the url when you repeat any of it."
2899
3825
  ].join(" ");
@@ -2926,31 +3852,41 @@ async function runGetRecipe(client, args) {
2926
3852
  }
2927
3853
  if (cached) notes.push("Served from this server's short-lived in-memory cache.");
2928
3854
  const payload = view.payload;
2929
- const lines = [
3855
+ const credit = creditLine([{ attribution: payload.attribution, url: payload.url }]);
3856
+ const head = [
2930
3857
  `${quoteForeign(payload.title)} \xB7 ${quoteForeign(payload.source_name)}`,
2931
3858
  quoteForeign(payload.url),
2932
3859
  renderYield(payload)
2933
3860
  ];
2934
- if (payload.ingredients.length > 0) {
2935
- lines.push(
2936
- "",
2937
- "Ingredients:",
2938
- ...payload.ingredients.map(
2939
- (entry) => entry.scaling === "unscaled" ? `- ${quoteForeign(entry.text)} (no quantity)` : `- ${quoteForeign(entry.text)}`
2940
- )
2941
- );
3861
+ const ingredientLines = payload.ingredients.map(
3862
+ (entry) => entry.scaling === "unscaled" ? `- ${quoteForeign(entry.text)} (no quantity)` : `- ${quoteForeign(entry.text)}`
3863
+ );
3864
+ const stepLines = payload.steps.map((step, index) => `${index + 1}. ${quoteForeign(step)}`);
3865
+ const room = roomForBody({ notes, credit }) - head.join("\n").length;
3866
+ const parts = [ingredientLines, stepLines].filter((part) => part.length > 0);
3867
+ const share = parts.length === 0 ? 0 : room / parts.length;
3868
+ const lines = [...head];
3869
+ const ingredients = fitLines(ingredientLines, share);
3870
+ if (ingredientLines.length > 0) {
3871
+ lines.push("", "Ingredients:", ...ingredients.lines);
3872
+ if (ingredients.hidden > 0) {
3873
+ lines.push(
3874
+ omittedLinesLine(ingredients.lines.length, ingredientLines.length, "ingredient lines")
3875
+ );
3876
+ }
2942
3877
  }
2943
- if (payload.steps.length > 0) {
2944
- lines.push(
2945
- "",
2946
- "Steps:",
2947
- ...payload.steps.map((step, index) => `${index + 1}. ${quoteForeign(step)}`)
2948
- );
3878
+ const steps = fitLines(stepLines, share);
3879
+ if (stepLines.length > 0) {
3880
+ lines.push("", "Steps:", ...steps.lines);
3881
+ if (steps.hidden > 0) {
3882
+ lines.push(omittedLinesLine(steps.lines.length, stepLines.length, "steps"));
3883
+ }
2949
3884
  }
2950
- return ok({ recipe: payload, id_read_as: read.inferred, notes }, lines.join("\n"), {
2951
- notes,
2952
- credit: creditLine([{ attribution: payload.attribution, url: payload.url }])
2953
- });
3885
+ return ok(
3886
+ { recipe: payload, id_read_as: read.inferred, notes: noteTexts(notes) },
3887
+ lines.join("\n"),
3888
+ { notes, credit }
3889
+ );
2954
3890
  } catch (error) {
2955
3891
  return toToolError(error);
2956
3892
  }
@@ -3008,6 +3944,10 @@ function resolveFactor2(args) {
3008
3944
  "Give 'factor', or both 'from_servings' and 'to_servings'."
3009
3945
  );
3010
3946
  }
3947
+ function statedFactor(factor) {
3948
+ const shortened = Math.round(factor * 1e3) / 1e3;
3949
+ return shortened === 0 ? Number(factor.toPrecision(3)) : shortened;
3950
+ }
3011
3951
  function runScaleIngredients(args) {
3012
3952
  try {
3013
3953
  const factor = resolveFactor2(args);
@@ -3047,13 +3987,13 @@ function runScaleIngredients(args) {
3047
3987
  const body = scaled.map((entry, index) => `${index + 1}. ${quoteForeign(entry.text)}`).join("\n");
3048
3988
  return ok(
3049
3989
  {
3050
- factor: Math.round(factor * 1e3) / 1e3,
3990
+ factor: statedFactor(factor),
3051
3991
  language: args.language,
3052
3992
  ingredients: scaled.map(toIngredientPayload),
3053
3993
  ...counts,
3054
3994
  notes
3055
3995
  },
3056
- `Scaled by ${Math.round(factor * 1e3) / 1e3}:
3996
+ `Scaled by ${statedFactor(factor)}:
3057
3997
  ${body}`,
3058
3998
  { notes, credit: "Source: the list you supplied. Scaled offline; nothing was fetched." }
3059
3999
  );
@@ -3068,8 +4008,11 @@ var searchRecipesDescription = [
3068
4008
  "Search every recipe source this server reads, at the same time, for a dish or an ingredient, and get one merged list.",
3069
4009
  "Each row carries the id get_recipe takes, and that id names the source it came from, so nothing has to be guessed afterwards.",
3070
4010
  "The sources are written in different languages and count their own results differently, so 'per_source' says what each one answered, what its own number means, and names any that failed. A short list is never evidence of what exists.",
3071
- "Some sources keep recipes and reference pages together, so a row can be a page about an ingredient rather than a recipe using it. Only get_recipe can tell them apart, and it says when a page carries no ingredient list.",
4011
+ "Some sources keep recipes and reference pages together, so a row can be a page about an ingredient rather than a recipe using it. Only get_recipe can tell them apart, and it says what it read off the page.",
3072
4012
  "The query goes to each source's own search as free text. There is no filtering: a word naming a diet, a time or a calorie count matches only where that source's index happens to carry it.",
4013
+ "Ask in a whole sentence if that is the question. These indexes answer the words they are handed, so a sentence is also sent as the words naming the dish and as the dish word alone, and the rows are the union; 'per_source' lists every wording and what it returned.",
4014
+ "What the question says the recipe must not hold is set aside from those shorter wordings rather than searched for, and named back in the notes: a negation, an allergy stated as one, a diet named in one word, and the number of people at the table. No source filters on any of it, so open a row with get_recipe and read the ingredient list before calling it suitable.",
4015
+ "A condition is read with the food on whichever side of it the sentence put one, so 'allergique aux noix' and 'peanut allergy' both name the nut. Where a sentence puts a food on neither side, the notes say a condition was stated and that its food was not read, because naming the wrong word would hide a dish and search for the food being avoided at once.",
3073
4016
  "Rows are interleaved one source at a time rather than ranked, because the sources share no score that could order them against each other."
3074
4017
  ].join(" ");
3075
4018
  var searchRecipesInput = strictInput({
@@ -3077,6 +4020,9 @@ var searchRecipesInput = strictInput({
3077
4020
  limit_per_source: z7.number().int().min(1).max(25).default(5).describe("Rows to take from each source, so one source cannot fill the whole list."),
3078
4021
  sources: z7.array(z7.string()).optional().describe(
3079
4022
  "Sources to ask, by id. Left out, they are all asked, which is the point of this tool. The ids are the ones 'per_source' reports."
4023
+ ),
4024
+ fan_out: z7.boolean().default(true).describe(
4025
+ "Whether a question may also be sent in shorter wordings derived from it. On, because these indexes answer the words they are handed: a question written as a sentence comes back empty from a corpus holding several of the dish. Turn it off to send exactly the words typed; 'per_source' then names the wordings that were withheld."
3080
4026
  )
3081
4027
  });
3082
4028
  var searchRecipesOutput = z7.object({
@@ -3088,31 +4034,77 @@ var searchRecipesOutput = z7.object({
3088
4034
  notes: z7.array(z7.string())
3089
4035
  });
3090
4036
  var WORDS_BEFORE_SAYING_SO = 4;
3091
- function titleCarries(title, query) {
3092
- const fold = (text2) => text2.normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/œ/g, "oe").toLowerCase().replace(/[^a-z0-9]+/g, " ");
3093
- const haystack = ` ${fold(title)} `;
3094
- const words = fold(query).split(" ").filter((word) => word.length > 2);
3095
- if (words.length === 0) return true;
3096
- return words.some((word) => haystack.includes(` ${word}`));
3097
- }
3098
4037
  async function runSearchRecipes(client, args) {
3099
4038
  try {
3100
4039
  const merged = await client.searchRecipes(
3101
4040
  args.query,
3102
4041
  args.limit_per_source,
3103
- args.sources
4042
+ args.sources,
4043
+ { fanOut: args.fan_out }
3104
4044
  );
3105
4045
  const results = merged.rows.map(toRowPayload);
3106
4046
  const notes = reportNotes(merged.reports);
3107
4047
  const answered = merged.reports.filter((report) => report.status === "answered");
3108
4048
  const contributed = merged.reports.filter((report) => report.count > 0);
3109
4049
  const order = contributed.length > 1 ? "One row from each source in turn, in the order each source returned them. No score orders them against each other." : contributed.length === 1 ? `Every row came from ${contributed[0].name}, in the order it returned them.` : "No source contributed a row.";
4050
+ const conditions = readConditions(args.query);
4051
+ const named = (kind) => conditions.conditions.filter((condition) => condition.kind === kind && condition.named !== null).map((condition) => `"${quoteForeign(condition.named)}"`).join(" and ");
4052
+ const unread = (kind) => conditions.conditions.some(
4053
+ (condition) => condition.kind === kind && condition.named === null
4054
+ );
4055
+ const allergies = named("allergy");
4056
+ if (allergies !== "") {
4057
+ notes.push(
4058
+ mustKeep(
4059
+ `This question states an allergy to ${allergies}. No source filtered on it, and none can: these are searches over what a page holds rather than over what it leaves out, so a row here may be a recipe built on that ingredient. Open every row with get_recipe and read the whole ingredient list before cooking from it.`
4060
+ )
4061
+ );
4062
+ }
4063
+ if (unread("allergy")) {
4064
+ notes.push(
4065
+ mustKeep(
4066
+ "This question states an allergy, and the words around it do not say plainly which food it names, so no food was read from it and none is named here. Nothing was set aside and no source filters on an allergy in any case: open every row with get_recipe and read the whole ingredient list before cooking from it."
4067
+ )
4068
+ );
4069
+ }
4070
+ const excluded = named("excluded");
4071
+ if (excluded !== "") {
4072
+ notes.push(
4073
+ mustKeep(
4074
+ `This question asks for a recipe free of ${excluded}. No source filtered on that, and none can: these are searches over what a page holds rather than over what it leaves out. Open a row with get_recipe and check the ingredient list before calling it suitable.`
4075
+ )
4076
+ );
4077
+ }
4078
+ if (unread("excluded")) {
4079
+ notes.push(
4080
+ mustKeep(
4081
+ "This question says the recipe must leave something out, and the words around it do not say plainly which food it names, so nothing was read from it and nothing is named here. Open a row with get_recipe and read the ingredient list before calling it suitable."
4082
+ )
4083
+ );
4084
+ }
4085
+ const diets = named("diet");
4086
+ if (diets !== "") {
4087
+ notes.push(
4088
+ mustKeep(
4089
+ `This question names a diet: ${diets}. A diet is a rule about what a recipe leaves out, and no source filters on one, so the word was sent as text and matches only where a source's index happens to carry it. Open a row with get_recipe and read the ingredient list before calling it suitable.`
4090
+ )
4091
+ );
4092
+ }
4093
+ if (conditions.servings !== null) {
4094
+ notes.push(
4095
+ mustKeep(
4096
+ `This question asks for ${conditions.servings} servings. That is not something a search matches, so it was not looked for. Pass 'servings' to get_recipe or compare_recipes to rescale a recipe once one is chosen.`
4097
+ )
4098
+ );
4099
+ }
3110
4100
  if (args.query.trim().split(/\s+/).length > WORDS_BEFORE_SAYING_SO) {
3111
4101
  notes.push(
3112
- "This query went to each source as free text and no filter was applied to it. Words like 'vegan' or 'under 300 calories' help only where a source's own index happens to match them, so read the rows rather than assuming they meet every condition."
4102
+ mustKeep(
4103
+ "This query went to each source as free text and no filter was applied to it. Anything the question said the recipe must not contain went out as words like the rest of it, so read the rows rather than assuming they meet it."
4104
+ )
3113
4105
  );
3114
4106
  }
3115
- if (results.length > 0 && !results.some((row) => titleCarries(row.title, args.query))) {
4107
+ if (results.length > 0 && !results.some((row) => namesDish(row.title, args.query))) {
3116
4108
  notes.push(
3117
4109
  `No title here carries a word of "${args.query}". These rows are what the sources ranked for that spelling, so read them as candidates to check rather than as recipes for the dish.`
3118
4110
  );
@@ -3122,8 +4114,17 @@ async function runSearchRecipes(client, args) {
3122
4114
  "Every source answered and none holds anything under this wording. Try the dish's name in another language, or name a main ingredient instead."
3123
4115
  );
3124
4116
  }
4117
+ const derived = [
4118
+ ...new Set(
4119
+ merged.reports.flatMap(
4120
+ (report) => report.wordings.filter((attempt) => attempt.ran && attempt.query !== args.query.trim()).map((attempt) => attempt.query)
4121
+ )
4122
+ )
4123
+ ];
4124
+ const alsoSent = derived.length > 0 ? `
4125
+ Also searched for: ${derived.map((wording) => `"${quoteForeign(wording)}"`).join(", ")}.` : "";
3125
4126
  const body = results.length > 0 ? `${results.length} recipes for "${args.query}":
3126
- ${renderRows(results)}` : answered.length === 0 ? `No source answered for "${args.query}", so nothing here says whether such a recipe exists.` : `Nothing came back for "${args.query}".`;
4127
+ ${renderRows(results)}${alsoSent}` : answered.length === 0 ? `No source answered for "${args.query}", so nothing here says whether such a recipe exists.` : `Nothing came back for "${args.query}".${alsoSent}`;
3127
4128
  return ok(
3128
4129
  {
3129
4130
  query: args.query,
@@ -3131,7 +4132,7 @@ ${renderRows(results)}` : answered.length === 0 ? `No source answered for "${arg
3131
4132
  result_count: results.length,
3132
4133
  per_source: merged.reports.map(toReportPayload),
3133
4134
  order,
3134
- notes
4135
+ notes: noteTexts(notes)
3135
4136
  },
3136
4137
  body,
3137
4138
  {