mcp-marmiton 1.0.4 → 1.2.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/README.md +32 -0
- package/dist/index.js +203 -76
- package/dist/index.js.map +1 -1
- package/dist/marmiton/client.d.ts +10 -1
- package/dist/marmiton/client.js +17 -9
- package/dist/marmiton/client.js.map +1 -1
- package/package.json +5 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -71,6 +71,15 @@ original: half a sachet at factor 0.667 becomes a third of a sachet, never a who
|
|
|
71
71
|
one. And scaling never **silently drops** an ingredient by rounding it to zero,
|
|
72
72
|
which is why small amounts come back as fractions.
|
|
73
73
|
|
|
74
|
+
Ranges are read as one quantity: "2 à 3 gousses" doubled reads "4 à 6 gousses",
|
|
75
|
+
and `amount` reports the upper bound, since that is what a cook buys.
|
|
76
|
+
|
|
77
|
+
Below a quarter there is nothing a kitchen can measure, so the amount is clamped
|
|
78
|
+
up and **the line stops holding its share of the recipe**. Half a sachet of
|
|
79
|
+
baking powder against three pots of flour comes out four times too strong that
|
|
80
|
+
way, so the ingredient and the response both say it happened rather than leaving
|
|
81
|
+
you to find out in the oven.
|
|
82
|
+
|
|
74
83
|
`scale_ingredients` exposes the same logic **without any network request**, so it
|
|
75
84
|
also works on a recipe pasted from somewhere else.
|
|
76
85
|
|
|
@@ -157,6 +166,19 @@ and use your judgement.
|
|
|
157
166
|
|
|
158
167
|
This is an unofficial project, with no affiliation to or endorsement by Marmiton.
|
|
159
168
|
|
|
169
|
+
## Contributing
|
|
170
|
+
|
|
171
|
+
Bugs, questions and ideas all belong in
|
|
172
|
+
[the issue tracker](https://github.com/smeet666/mcp-marmiton/issues). Pull requests
|
|
173
|
+
are welcome; please open an issue first so we can agree on what the right
|
|
174
|
+
answer is before you write it. [CONTRIBUTING.md](CONTRIBUTING.md) has the
|
|
175
|
+
detail, and [SECURITY.md](SECURITY.md) covers anything exploitable.
|
|
176
|
+
|
|
177
|
+
## Support
|
|
178
|
+
|
|
179
|
+
These servers are free and stay free. If one of them saved you an afternoon,
|
|
180
|
+
you can [buy me a coffee](https://buymeacoffee.com/smeet666).
|
|
181
|
+
|
|
160
182
|
## License
|
|
161
183
|
|
|
162
184
|
MIT. See [LICENSE](./LICENSE). The license covers this source code only, not the
|
|
@@ -229,6 +251,16 @@ l'originale : un demi-sachet au facteur 0,667 devient un tiers de sachet, jamais
|
|
|
229
251
|
sachet entier. Et l'adaptation ne **supprime jamais** un ingrédient en l'arrondissant
|
|
230
252
|
à zéro, d'où les fractions pour les petites quantités.
|
|
231
253
|
|
|
254
|
+
Les fourchettes sont lues comme une seule quantité : « 2 à 3 gousses » doublé
|
|
255
|
+
donne « 4 à 6 gousses », et `amount` renvoie la borne haute, celle que l'on
|
|
256
|
+
achète.
|
|
257
|
+
|
|
258
|
+
Sous un quart, il n'y a plus rien qu'une cuisine puisse mesurer : la quantité est
|
|
259
|
+
alors remontée et **la ligne cesse de tenir sa part de la recette**. Un demi-sachet
|
|
260
|
+
de levure face à trois pots de farine ressort ainsi quatre fois trop dosé, donc
|
|
261
|
+
l'ingrédient et la réponse le disent, plutôt que de vous le laisser découvrir au
|
|
262
|
+
four.
|
|
263
|
+
|
|
232
264
|
`scale_ingredients` expose la même logique **sans aucune requête réseau**, ce qui
|
|
233
265
|
permet aussi d'adapter une recette venue d'ailleurs.
|
|
234
266
|
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
|
|
6
6
|
// src/version.ts
|
|
7
|
-
var PKG_VERSION = "1.0
|
|
7
|
+
var PKG_VERSION = "1.2.0";
|
|
8
8
|
var REPO_URL = "https://github.com/smeet666/mcp-marmiton";
|
|
9
9
|
|
|
10
10
|
// src/config.ts
|
|
@@ -646,19 +646,26 @@ var MarmitonClient = class {
|
|
|
646
646
|
*/
|
|
647
647
|
async search(query) {
|
|
648
648
|
const url = buildSearchUrl(query);
|
|
649
|
-
|
|
650
|
-
return { data: parseSearchPage(html, url), cached };
|
|
649
|
+
return this.fetchParsed(url, (html) => parseSearchPage(html, url));
|
|
651
650
|
}
|
|
652
651
|
async getRecipe(ref) {
|
|
653
652
|
const { id, url } = resolveRecipeRef(ref);
|
|
654
|
-
|
|
655
|
-
return { data: parseRecipePage(html, { id, url }), cached };
|
|
653
|
+
return this.fetchParsed(url, (html) => parseRecipePage(html, { id, url }));
|
|
656
654
|
}
|
|
657
|
-
|
|
655
|
+
/**
|
|
656
|
+
* Fetch, parse, then cache. In that order: a page that could not be read is
|
|
657
|
+
* never stored, so a bad minute at Marmiton cannot be replayed from memory for
|
|
658
|
+
* the rest of the cache lifetime, leaving the tool unable to recover after
|
|
659
|
+
* the site comes back.
|
|
660
|
+
*
|
|
661
|
+
* The cached value is the parsed result rather than the raw page, which also
|
|
662
|
+
* keeps a few hundred kilobytes of markup per entry out of memory.
|
|
663
|
+
*/
|
|
664
|
+
async fetchParsed(url, parse) {
|
|
658
665
|
const hit = this.cache.get(url);
|
|
659
666
|
if (hit !== void 0) {
|
|
660
667
|
this.logger.debug(`cache hit ${url}`);
|
|
661
|
-
return {
|
|
668
|
+
return { data: hit, cached: true };
|
|
662
669
|
}
|
|
663
670
|
const html = await fetchHtml(url, {
|
|
664
671
|
config: this.config,
|
|
@@ -666,8 +673,9 @@ var MarmitonClient = class {
|
|
|
666
673
|
logger: this.logger,
|
|
667
674
|
...this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}
|
|
668
675
|
});
|
|
669
|
-
|
|
670
|
-
|
|
676
|
+
const data = parse(html);
|
|
677
|
+
this.cache.set(url, data);
|
|
678
|
+
return { data, cached: false };
|
|
671
679
|
}
|
|
672
680
|
};
|
|
673
681
|
|
|
@@ -677,22 +685,22 @@ import { z as z2 } from "zod";
|
|
|
677
685
|
// src/recipe/units.ts
|
|
678
686
|
var UNITS = {
|
|
679
687
|
// Mass
|
|
680
|
-
g: { canonical: "g", kind: "measured" },
|
|
681
|
-
gr: { canonical: "g", kind: "measured" },
|
|
682
|
-
gramme: { canonical: "g", kind: "measured" },
|
|
683
|
-
grammes: { canonical: "g", kind: "measured" },
|
|
684
|
-
kg: { canonical: "kg", kind: "measured" },
|
|
685
|
-
kilo: { canonical: "kg", kind: "measured" },
|
|
686
|
-
kilos: { canonical: "kg", kind: "measured" },
|
|
687
|
-
kilogramme: { canonical: "kg", kind: "measured" },
|
|
688
|
-
mg: { canonical: "mg", kind: "measured" },
|
|
688
|
+
g: { canonical: "g", kind: "measured", symbol: true },
|
|
689
|
+
gr: { canonical: "g", kind: "measured", symbol: true },
|
|
690
|
+
gramme: { canonical: "g", kind: "measured", symbol: true },
|
|
691
|
+
grammes: { canonical: "g", kind: "measured", symbol: true },
|
|
692
|
+
kg: { canonical: "kg", kind: "measured", symbol: true },
|
|
693
|
+
kilo: { canonical: "kg", kind: "measured", symbol: true },
|
|
694
|
+
kilos: { canonical: "kg", kind: "measured", symbol: true },
|
|
695
|
+
kilogramme: { canonical: "kg", kind: "measured", symbol: true },
|
|
696
|
+
mg: { canonical: "mg", kind: "measured", symbol: true },
|
|
689
697
|
// Volume
|
|
690
|
-
ml: { canonical: "ml", kind: "measured" },
|
|
691
|
-
cl: { canonical: "cl", kind: "measured" },
|
|
692
|
-
dl: { canonical: "dl", kind: "measured" },
|
|
693
|
-
l: { canonical: "l", kind: "measured" },
|
|
694
|
-
litre: { canonical: "l", kind: "measured" },
|
|
695
|
-
litres: { canonical: "l", kind: "measured" },
|
|
698
|
+
ml: { canonical: "ml", kind: "measured", symbol: true },
|
|
699
|
+
cl: { canonical: "cl", kind: "measured", symbol: true },
|
|
700
|
+
dl: { canonical: "dl", kind: "measured", symbol: true },
|
|
701
|
+
l: { canonical: "l", kind: "measured", symbol: true },
|
|
702
|
+
litre: { canonical: "l", kind: "measured", symbol: true },
|
|
703
|
+
litres: { canonical: "l", kind: "measured", symbol: true },
|
|
696
704
|
// Spoons and cups: real measures, but only in sensible fractions.
|
|
697
705
|
"cuillere a soupe": {
|
|
698
706
|
canonical: "cuill\xE8re \xE0 soupe",
|
|
@@ -776,6 +784,12 @@ var DEMOTIONS = {
|
|
|
776
784
|
cl: { to: "ml", per: 10 },
|
|
777
785
|
g: { to: "mg", per: 1e3 }
|
|
778
786
|
};
|
|
787
|
+
function demoteUnit(unit) {
|
|
788
|
+
const step = DEMOTIONS[unit.canonical];
|
|
789
|
+
if (!step) return null;
|
|
790
|
+
const target = lookupUnit(step.to);
|
|
791
|
+
return target ? { unit: target, per: step.per } : null;
|
|
792
|
+
}
|
|
779
793
|
function convertToReadableUnit(unit, amount) {
|
|
780
794
|
if (unit.kind !== "measured" || !Number.isFinite(amount) || amount <= 0) {
|
|
781
795
|
return { amount, unit };
|
|
@@ -793,9 +807,9 @@ function convertToReadableUnit(unit, amount) {
|
|
|
793
807
|
return { amount, unit };
|
|
794
808
|
}
|
|
795
809
|
function formatUnit(unit, amount) {
|
|
810
|
+
if (unit.symbol) return unit.canonical;
|
|
796
811
|
if (amount < 2) return unit.canonical;
|
|
797
812
|
if (unit.plural) return unit.plural;
|
|
798
|
-
if (/^[a-z]{1,3}$/.test(unit.canonical)) return unit.canonical;
|
|
799
813
|
return `${unit.canonical}s`;
|
|
800
814
|
}
|
|
801
815
|
|
|
@@ -845,9 +859,18 @@ function parseLeadingQuantity(text) {
|
|
|
845
859
|
function parseIngredient(line) {
|
|
846
860
|
const original = line;
|
|
847
861
|
const text = line.trim();
|
|
848
|
-
const
|
|
862
|
+
const range = parseLeadingRange(text);
|
|
863
|
+
const quantity = range ?? parseLeadingQuantity(text);
|
|
849
864
|
if (!quantity) {
|
|
850
|
-
return {
|
|
865
|
+
return {
|
|
866
|
+
original,
|
|
867
|
+
amount: null,
|
|
868
|
+
amountMax: null,
|
|
869
|
+
rangeSeparator: null,
|
|
870
|
+
unit: null,
|
|
871
|
+
unitText: null,
|
|
872
|
+
item: text
|
|
873
|
+
};
|
|
851
874
|
}
|
|
852
875
|
let rest = text.slice(quantity.length).trimStart();
|
|
853
876
|
let unit = null;
|
|
@@ -864,7 +887,31 @@ function parseIngredient(line) {
|
|
|
864
887
|
}
|
|
865
888
|
}
|
|
866
889
|
const item = rest.replace(/^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+)/i, "").trim();
|
|
867
|
-
return {
|
|
890
|
+
return {
|
|
891
|
+
original,
|
|
892
|
+
amount: quantity.amount,
|
|
893
|
+
amountMax: range?.max ?? null,
|
|
894
|
+
rangeSeparator: range?.separator ?? null,
|
|
895
|
+
unit,
|
|
896
|
+
unitText,
|
|
897
|
+
item
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
function parseLeadingRange(text) {
|
|
901
|
+
const low = parseLeadingQuantity(text);
|
|
902
|
+
if (!low) return null;
|
|
903
|
+
const after = text.slice(low.length);
|
|
904
|
+
const separator = /^\s*(à|a|ou|-|–|—|\/)\s*/.exec(after);
|
|
905
|
+
if (!separator) return null;
|
|
906
|
+
if (separator[1] === "/") return null;
|
|
907
|
+
const high = parseLeadingQuantity(after.slice(separator[0].length));
|
|
908
|
+
if (!high || high.amount <= low.amount) return null;
|
|
909
|
+
return {
|
|
910
|
+
amount: low.amount,
|
|
911
|
+
max: high.amount,
|
|
912
|
+
separator: separator[1],
|
|
913
|
+
length: low.length + separator[0].length + high.length
|
|
914
|
+
};
|
|
868
915
|
}
|
|
869
916
|
function formatAmount(amount, options = {}) {
|
|
870
917
|
if (!Number.isFinite(amount)) return "";
|
|
@@ -899,17 +946,38 @@ function roundMeasured(value) {
|
|
|
899
946
|
if (value >= 1) return roundTo(value, 0.5);
|
|
900
947
|
return Math.round(value * 100) / 100;
|
|
901
948
|
}
|
|
902
|
-
|
|
903
|
-
|
|
949
|
+
var SMALLEST_USABLE_FRACTION = 0.25;
|
|
950
|
+
function roundCountable(value, allowHalves, ceiling) {
|
|
951
|
+
if (value <= 0) return { value: 0, clamped: false };
|
|
904
952
|
if (value < 1) {
|
|
905
|
-
const
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
953
|
+
const candidates = [SMALLEST_USABLE_FRACTION, 1 / 3, 0.5, 2 / 3, 0.75, 1].filter(
|
|
954
|
+
(candidate) => candidate <= Math.max(ceiling, SMALLEST_USABLE_FRACTION)
|
|
955
|
+
);
|
|
956
|
+
let closest = candidates[0];
|
|
957
|
+
for (const candidate of candidates) {
|
|
958
|
+
if (Math.abs(value - candidate) < Math.abs(value - closest)) closest = candidate;
|
|
909
959
|
}
|
|
910
|
-
return
|
|
960
|
+
return {
|
|
961
|
+
value: Math.round(closest * 100) / 100,
|
|
962
|
+
clamped: value < SMALLEST_USABLE_FRACTION
|
|
963
|
+
};
|
|
911
964
|
}
|
|
912
|
-
return allowHalves ? roundTo(value, 0.5) : Math.round(value);
|
|
965
|
+
return { value: allowHalves ? roundTo(value, 0.5) : Math.round(value), clamped: false };
|
|
966
|
+
}
|
|
967
|
+
function roundMeasuredInUsableUnit(unit, raw) {
|
|
968
|
+
let current = unit;
|
|
969
|
+
let value = raw;
|
|
970
|
+
while (value > 0 && value < 1) {
|
|
971
|
+
const step = demoteUnit(current);
|
|
972
|
+
if (!step) break;
|
|
973
|
+
value *= step.per;
|
|
974
|
+
current = step.unit;
|
|
975
|
+
}
|
|
976
|
+
const rounded = roundMeasured(value);
|
|
977
|
+
return {
|
|
978
|
+
amount: rounded === 0 && value > 0 ? Number(value.toPrecision(2)) : rounded,
|
|
979
|
+
unit: current
|
|
980
|
+
};
|
|
913
981
|
}
|
|
914
982
|
function agreeWithAmount(item, amount) {
|
|
915
983
|
if (!item) return item;
|
|
@@ -927,6 +995,12 @@ function agreeWithAmount(item, amount) {
|
|
|
927
995
|
}
|
|
928
996
|
return words.join(" ");
|
|
929
997
|
}
|
|
998
|
+
var MUTE_H_WORDS = /^(?:huile|huiles|huitre|huitres|huître|huîtres|herbe|herbes|hysope)\b/i;
|
|
999
|
+
function joinItem(item) {
|
|
1000
|
+
if (!item) return "";
|
|
1001
|
+
const elides = /^[aeiouàâäéèêëîïôöûü]/i.test(item) || MUTE_H_WORDS.test(item);
|
|
1002
|
+
return elides ? ` d'${item}` : ` de ${item}`;
|
|
1003
|
+
}
|
|
930
1004
|
function scaleIngredient(line, options) {
|
|
931
1005
|
const { factor } = options;
|
|
932
1006
|
const parsed = parseIngredient(line);
|
|
@@ -937,6 +1011,7 @@ function scaleIngredient(line, options) {
|
|
|
937
1011
|
amount: null,
|
|
938
1012
|
unit: null,
|
|
939
1013
|
scaling: "unscaled",
|
|
1014
|
+
adjusted: false,
|
|
940
1015
|
note: "No quantity given; adjust to taste."
|
|
941
1016
|
};
|
|
942
1017
|
}
|
|
@@ -948,41 +1023,47 @@ function scaleIngredient(line, options) {
|
|
|
948
1023
|
amount: parsed.amount,
|
|
949
1024
|
unit: parsed.unit?.canonical ?? null,
|
|
950
1025
|
scaling: "unscaled",
|
|
1026
|
+
adjusted: false,
|
|
951
1027
|
note: "Measure is approximate by nature; adjust to taste."
|
|
952
1028
|
};
|
|
953
1029
|
}
|
|
954
|
-
const
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
unit = converted.unit;
|
|
962
|
-
scaling = "scaled";
|
|
963
|
-
} else if (kind === "portioned") {
|
|
1030
|
+
const scaleBound = (source) => {
|
|
1031
|
+
const raw = source * factor;
|
|
1032
|
+
if (kind === "measured") {
|
|
1033
|
+
const converted = roundMeasuredInUsableUnit(parsed.unit, raw);
|
|
1034
|
+
const readable = convertToReadableUnit(converted.unit, converted.amount);
|
|
1035
|
+
return { amount: readable.amount, unit: readable.unit, raw, clamped: false };
|
|
1036
|
+
}
|
|
964
1037
|
const allowHalves = /cuillère|verre|tasse|bol/.test(parsed.unit?.canonical ?? "");
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1038
|
+
const ceiling = factor < 1 ? source : Number.POSITIVE_INFINITY;
|
|
1039
|
+
const rounded = roundCountable(raw, allowHalves, ceiling);
|
|
1040
|
+
return { amount: rounded.value, unit: parsed.unit, raw, clamped: rounded.clamped };
|
|
1041
|
+
};
|
|
1042
|
+
const low = scaleBound(parsed.amount);
|
|
1043
|
+
const high = parsed.amountMax === null ? null : scaleBound(parsed.amountMax);
|
|
1044
|
+
const amount = high?.amount ?? low.amount;
|
|
1045
|
+
const unit = high?.unit ?? low.unit;
|
|
1046
|
+
const scaling = kind === "measured" ? "scaled" : "rounded";
|
|
971
1047
|
const unitLabel = unit ? ` ${formatUnit(unit, amount)}` : "";
|
|
972
|
-
const separator = unit ? " de " : " ";
|
|
973
1048
|
const itemText = unit ? parsed.item : agreeWithAmount(parsed.item, amount);
|
|
974
|
-
const item = itemText ?
|
|
975
|
-
const
|
|
1049
|
+
const item = unit ? joinItem(parsed.item) : itemText ? ` ${itemText}` : "";
|
|
1050
|
+
const asText = (value) => formatAmount(value, { fractions: kind !== "measured" });
|
|
1051
|
+
const amountText = high === null ? asText(low.amount) : parsed.rangeSeparator === "-" || parsed.rangeSeparator === "\u2013" || parsed.rangeSeparator === "\u2014" ? `${asText(low.amount)}${parsed.rangeSeparator}${asText(high.amount)}` : `${asText(low.amount)} ${parsed.rangeSeparator} ${asText(high.amount)}`;
|
|
976
1052
|
const text = `${amountText}${unitLabel}${item}`.trim();
|
|
1053
|
+
const bounds = high === null ? [low] : [low, high];
|
|
1054
|
+
const adjusted = bounds.some((bound) => Math.abs(bound.raw - bound.amount) > 0.01);
|
|
977
1055
|
const result = {
|
|
978
1056
|
original: parsed.original,
|
|
979
1057
|
text,
|
|
980
1058
|
amount,
|
|
981
1059
|
unit: unit?.canonical ?? null,
|
|
982
|
-
scaling
|
|
1060
|
+
scaling,
|
|
1061
|
+
adjusted
|
|
983
1062
|
};
|
|
984
|
-
if (
|
|
985
|
-
result.note = `
|
|
1063
|
+
if (bounds.some((bound) => bound.clamped)) {
|
|
1064
|
+
result.note = `Clamped up to ${formatAmount(SMALLEST_USABLE_FRACTION)} from ${formatAmount(Math.round(low.raw * 1e3) / 1e3)}, the smallest amount worth measuring. This line no longer holds its share of the recipe.`;
|
|
1065
|
+
} else if (scaling === "rounded" && adjusted) {
|
|
1066
|
+
result.note = `Rounded from ${formatAmount(Math.round(low.raw * 100) / 100)}.`;
|
|
986
1067
|
}
|
|
987
1068
|
return result;
|
|
988
1069
|
}
|
|
@@ -997,7 +1078,8 @@ function passthroughIngredients(lines) {
|
|
|
997
1078
|
text: parsed.original,
|
|
998
1079
|
amount: parsed.amount,
|
|
999
1080
|
unit: parsed.unit?.canonical ?? null,
|
|
1000
|
-
scaling: "unscaled"
|
|
1081
|
+
scaling: "unscaled",
|
|
1082
|
+
adjusted: false
|
|
1001
1083
|
};
|
|
1002
1084
|
});
|
|
1003
1085
|
}
|
|
@@ -1015,11 +1097,19 @@ var scaledIngredientSchema = z.object({
|
|
|
1015
1097
|
scaling: z.enum(["scaled", "rounded", "unscaled"]).describe(
|
|
1016
1098
|
"'scaled' was multiplied cleanly. 'rounded' was multiplied then rounded to something countable. 'unscaled' was left alone, either because it carries no quantity or because the measure is approximate by nature."
|
|
1017
1099
|
),
|
|
1018
|
-
|
|
1100
|
+
adjusted: z.boolean().describe(
|
|
1101
|
+
"True when rounding moved the value away from the exact product. A line can be 'rounded' and still land exactly, as three eggs doubled land on six."
|
|
1102
|
+
),
|
|
1103
|
+
note: z.string().optional().describe("Why the line was rounded, clamped or left alone.")
|
|
1019
1104
|
});
|
|
1020
|
-
function ok(structured, text) {
|
|
1105
|
+
function ok(structured, text, notes = []) {
|
|
1106
|
+
const trailer = notes.map((note) => `Note: ${note}`).join("\n");
|
|
1107
|
+
const budget = MAX_TEXT_MIRROR_CHARS - (trailer ? trailer.length + 2 : 0);
|
|
1108
|
+
const body = truncate(text, Math.max(0, budget));
|
|
1021
1109
|
return {
|
|
1022
|
-
content: [{ type: "text", text:
|
|
1110
|
+
content: [{ type: "text", text: trailer ? `${body}
|
|
1111
|
+
|
|
1112
|
+
${trailer}` : body }],
|
|
1023
1113
|
structuredContent: structured
|
|
1024
1114
|
};
|
|
1025
1115
|
}
|
|
@@ -1111,8 +1201,21 @@ async function runGetRecipe(client, args) {
|
|
|
1111
1201
|
notes.push(
|
|
1112
1202
|
`Quantities rescaled from ${originalCount} to ${args.servings} ${unit} (factor ${Math.round(factor * 100) / 100}).`
|
|
1113
1203
|
);
|
|
1114
|
-
|
|
1115
|
-
|
|
1204
|
+
const roundedCount = ingredients.filter(
|
|
1205
|
+
(entry) => entry.scaling === "rounded" && entry.adjusted
|
|
1206
|
+
).length;
|
|
1207
|
+
if (roundedCount > 0) {
|
|
1208
|
+
notes.push(
|
|
1209
|
+
`${roundedCount} quantity(ies) were rounded to remain usable; see the 'scaling' field.`
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
const clampedCount = ingredients.filter(
|
|
1213
|
+
(entry) => /clamped up/i.test(entry.note ?? "")
|
|
1214
|
+
).length;
|
|
1215
|
+
if (clampedCount > 0) {
|
|
1216
|
+
notes.push(
|
|
1217
|
+
`${clampedCount} quantity(ies) fell below the smallest amount worth measuring and were clamped up, so their proportions no longer match the published recipe.`
|
|
1218
|
+
);
|
|
1116
1219
|
}
|
|
1117
1220
|
if (ingredients.some((entry) => entry.scaling === "unscaled")) {
|
|
1118
1221
|
notes.push("Some ingredients carry no usable quantity and were left as published.");
|
|
@@ -1165,14 +1268,20 @@ async function runGetRecipe(client, args) {
|
|
|
1165
1268
|
args.servings !== void 0 && factor !== null ? `Pour ${args.servings} ${data.recipeYield.unit ?? "portions"} (recette pr\xE9vue pour ${data.recipeYield.text}).` : `Recette pour ${data.recipeYield.text || "portions non pr\xE9cis\xE9es"}.`,
|
|
1166
1269
|
times
|
|
1167
1270
|
].filter(Boolean).join("\n");
|
|
1271
|
+
const rescaled = args.servings !== void 0 && factor !== null;
|
|
1168
1272
|
const ingredientLines = ingredients.map((entry) => {
|
|
1169
|
-
const flag = entry.scaling === "unscaled" ? " (non ajust\xE9)" : "";
|
|
1273
|
+
const flag = rescaled && entry.scaling === "unscaled" ? " (non ajust\xE9)" : "";
|
|
1170
1274
|
return `- ${entry.text}${flag}`;
|
|
1171
1275
|
}).join("\n");
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1276
|
+
const stepLines = data.steps.map((step, index) => `${index + 1}. ${step}`).join("\n");
|
|
1277
|
+
const body = [
|
|
1278
|
+
header,
|
|
1279
|
+
`Ingr\xE9dients:
|
|
1280
|
+
${ingredientLines}`,
|
|
1281
|
+
stepLines ? `Pr\xE9paration:
|
|
1282
|
+
${stepLines}` : ""
|
|
1283
|
+
].filter(Boolean).join("\n\n");
|
|
1284
|
+
return ok(structured, body, notes);
|
|
1176
1285
|
} catch (error) {
|
|
1177
1286
|
return toToolError(error);
|
|
1178
1287
|
}
|
|
@@ -1198,15 +1307,26 @@ var scaleIngredientsOutputShape = {
|
|
|
1198
1307
|
factor: z3.number(),
|
|
1199
1308
|
ingredients: z3.array(scaledIngredientSchema),
|
|
1200
1309
|
scaled_count: z3.number().int(),
|
|
1201
|
-
rounded_count: z3.number().int()
|
|
1310
|
+
rounded_count: z3.number().int().describe(
|
|
1311
|
+
"Lines whose value rounding moved away from the exact product, not lines that could have been rounded."
|
|
1312
|
+
),
|
|
1202
1313
|
unscaled_count: z3.number().int(),
|
|
1203
1314
|
notes: z3.array(z3.string())
|
|
1204
1315
|
};
|
|
1316
|
+
function formatFactor(factor) {
|
|
1317
|
+
return String(Number(factor.toPrecision(3)));
|
|
1318
|
+
}
|
|
1205
1319
|
function runScaleIngredients(args) {
|
|
1206
1320
|
try {
|
|
1207
1321
|
let factor;
|
|
1322
|
+
const notes = [];
|
|
1208
1323
|
if (args.factor !== void 0) {
|
|
1209
1324
|
factor = args.factor;
|
|
1325
|
+
if (args.from_servings !== void 0 || args.to_servings !== void 0) {
|
|
1326
|
+
notes.push(
|
|
1327
|
+
"'factor' was given alongside 'from_servings'/'to_servings'. 'factor' was applied and the pair ignored; send only one of the two to remove the ambiguity."
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1210
1330
|
} else if (args.from_servings !== void 0 && args.to_servings !== void 0) {
|
|
1211
1331
|
factor = args.to_servings / args.from_servings;
|
|
1212
1332
|
} else {
|
|
@@ -1218,10 +1338,12 @@ function runScaleIngredients(args) {
|
|
|
1218
1338
|
const ingredients = scaleIngredients(args.ingredients, { factor });
|
|
1219
1339
|
const counts = {
|
|
1220
1340
|
scaled: ingredients.filter((entry) => entry.scaling === "scaled").length,
|
|
1221
|
-
|
|
1222
|
-
|
|
1341
|
+
// Lines that were actually moved, rather than lines that merely belong to
|
|
1342
|
+
// the roundable category: at a factor of 100 every egg lands whole.
|
|
1343
|
+
rounded: ingredients.filter((entry) => entry.scaling === "rounded" && entry.adjusted).length,
|
|
1344
|
+
unscaled: ingredients.filter((entry) => entry.scaling === "unscaled").length,
|
|
1345
|
+
clamped: ingredients.filter((entry) => /clamped up/i.test(entry.note ?? "")).length
|
|
1223
1346
|
};
|
|
1224
|
-
const notes = [];
|
|
1225
1347
|
if (counts.rounded > 0) {
|
|
1226
1348
|
notes.push(
|
|
1227
1349
|
`${counts.rounded} quantity(ies) were rounded to stay usable, rather than left as fractions.`
|
|
@@ -1232,8 +1354,13 @@ function runScaleIngredients(args) {
|
|
|
1232
1354
|
`${counts.unscaled} line(s) carry no usable quantity and were returned unchanged; adjust to taste.`
|
|
1233
1355
|
);
|
|
1234
1356
|
}
|
|
1357
|
+
if (counts.clamped > 0) {
|
|
1358
|
+
notes.push(
|
|
1359
|
+
`${counts.clamped} quantity(ies) fell below the smallest amount worth measuring and were clamped up, so their proportions no longer match the original recipe.`
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1235
1362
|
const structured = {
|
|
1236
|
-
factor:
|
|
1363
|
+
factor: Number(factor.toPrecision(3)),
|
|
1237
1364
|
ingredients,
|
|
1238
1365
|
scaled_count: counts.scaled,
|
|
1239
1366
|
rounded_count: counts.rounded,
|
|
@@ -1244,8 +1371,8 @@ function runScaleIngredients(args) {
|
|
|
1244
1371
|
const flag = entry.scaling === "unscaled" ? " (non ajust\xE9)" : "";
|
|
1245
1372
|
return `- ${entry.text}${flag}`;
|
|
1246
1373
|
}).join("\n");
|
|
1247
|
-
return ok(structured, `Facteur ${
|
|
1248
|
-
${lines}
|
|
1374
|
+
return ok(structured, `Facteur ${formatFactor(factor)}:
|
|
1375
|
+
${lines}`, notes);
|
|
1249
1376
|
} catch (error) {
|
|
1250
1377
|
return toToolError(error);
|
|
1251
1378
|
}
|