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/CHANGELOG.md +239 -0
- package/README.md +174 -7
- package/dist/index.js +1171 -170
- package/dist/index.js.map +1 -1
- package/dist/recipe/scale.js +185 -38
- package/dist/recipe/scale.js.map +1 -1
- package/dist/sources/client.d.ts +180 -3
- package/dist/sources/client.js +657 -36
- package/dist/sources/client.js.map +1 -1
- package/package.json +3 -3
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.
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
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
|
|
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
|
|
1413
|
-
|
|
1414
|
-
return { unit, rest
|
|
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
|
|
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 = (
|
|
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
|
-
|
|
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
|
|
1477
|
-
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
|
-
|
|
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:
|
|
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
|
|
1494
|
-
alternateStyle: slashed ? "slash" :
|
|
1495
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1554
|
-
const
|
|
1555
|
-
|
|
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
|
|
1561
|
-
const after =
|
|
1562
|
-
if (!after.unit || after.rest.trim() !== "") return
|
|
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
|
|
1571
|
-
return { measures,
|
|
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
|
|
2711
|
+
function foldWord2(word) {
|
|
1970
2712
|
return word.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
|
|
1971
2713
|
}
|
|
1972
2714
|
function agreeTrailingAdjective(word, wantsPlural) {
|
|
1973
|
-
const folded =
|
|
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(
|
|
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
|
|
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 (
|
|
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 =
|
|
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(
|
|
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((
|
|
2101
|
-
const movedAlternate = alternateBounds.some((
|
|
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
|
|
2120
|
-
const
|
|
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) => !
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2384
|
-
const
|
|
2385
|
-
|
|
2386
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
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
|
-
|
|
2805
|
-
`${report.
|
|
2806
|
-
|
|
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
|
-
|
|
2811
|
-
`${
|
|
2812
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
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
|
|
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
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
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
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
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(
|
|
2951
|
-
notes,
|
|
2952
|
-
|
|
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:
|
|
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 ${
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
|
{
|