mcp-marmiton 1.1.0 → 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 +187 -68
- package/dist/index.js.map +1 -1
- package/dist/marmiton/client.js +1 -1
- 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.
|
|
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
|
|
@@ -685,22 +685,22 @@ import { z as z2 } from "zod";
|
|
|
685
685
|
// src/recipe/units.ts
|
|
686
686
|
var UNITS = {
|
|
687
687
|
// Mass
|
|
688
|
-
g: { canonical: "g", kind: "measured" },
|
|
689
|
-
gr: { canonical: "g", kind: "measured" },
|
|
690
|
-
gramme: { canonical: "g", kind: "measured" },
|
|
691
|
-
grammes: { canonical: "g", kind: "measured" },
|
|
692
|
-
kg: { canonical: "kg", kind: "measured" },
|
|
693
|
-
kilo: { canonical: "kg", kind: "measured" },
|
|
694
|
-
kilos: { canonical: "kg", kind: "measured" },
|
|
695
|
-
kilogramme: { canonical: "kg", kind: "measured" },
|
|
696
|
-
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 },
|
|
697
697
|
// Volume
|
|
698
|
-
ml: { canonical: "ml", kind: "measured" },
|
|
699
|
-
cl: { canonical: "cl", kind: "measured" },
|
|
700
|
-
dl: { canonical: "dl", kind: "measured" },
|
|
701
|
-
l: { canonical: "l", kind: "measured" },
|
|
702
|
-
litre: { canonical: "l", kind: "measured" },
|
|
703
|
-
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 },
|
|
704
704
|
// Spoons and cups: real measures, but only in sensible fractions.
|
|
705
705
|
"cuillere a soupe": {
|
|
706
706
|
canonical: "cuill\xE8re \xE0 soupe",
|
|
@@ -784,6 +784,12 @@ var DEMOTIONS = {
|
|
|
784
784
|
cl: { to: "ml", per: 10 },
|
|
785
785
|
g: { to: "mg", per: 1e3 }
|
|
786
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
|
+
}
|
|
787
793
|
function convertToReadableUnit(unit, amount) {
|
|
788
794
|
if (unit.kind !== "measured" || !Number.isFinite(amount) || amount <= 0) {
|
|
789
795
|
return { amount, unit };
|
|
@@ -801,9 +807,9 @@ function convertToReadableUnit(unit, amount) {
|
|
|
801
807
|
return { amount, unit };
|
|
802
808
|
}
|
|
803
809
|
function formatUnit(unit, amount) {
|
|
810
|
+
if (unit.symbol) return unit.canonical;
|
|
804
811
|
if (amount < 2) return unit.canonical;
|
|
805
812
|
if (unit.plural) return unit.plural;
|
|
806
|
-
if (/^[a-z]{1,3}$/.test(unit.canonical)) return unit.canonical;
|
|
807
813
|
return `${unit.canonical}s`;
|
|
808
814
|
}
|
|
809
815
|
|
|
@@ -853,9 +859,18 @@ function parseLeadingQuantity(text) {
|
|
|
853
859
|
function parseIngredient(line) {
|
|
854
860
|
const original = line;
|
|
855
861
|
const text = line.trim();
|
|
856
|
-
const
|
|
862
|
+
const range = parseLeadingRange(text);
|
|
863
|
+
const quantity = range ?? parseLeadingQuantity(text);
|
|
857
864
|
if (!quantity) {
|
|
858
|
-
return {
|
|
865
|
+
return {
|
|
866
|
+
original,
|
|
867
|
+
amount: null,
|
|
868
|
+
amountMax: null,
|
|
869
|
+
rangeSeparator: null,
|
|
870
|
+
unit: null,
|
|
871
|
+
unitText: null,
|
|
872
|
+
item: text
|
|
873
|
+
};
|
|
859
874
|
}
|
|
860
875
|
let rest = text.slice(quantity.length).trimStart();
|
|
861
876
|
let unit = null;
|
|
@@ -872,7 +887,31 @@ function parseIngredient(line) {
|
|
|
872
887
|
}
|
|
873
888
|
}
|
|
874
889
|
const item = rest.replace(/^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+)/i, "").trim();
|
|
875
|
-
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
|
+
};
|
|
876
915
|
}
|
|
877
916
|
function formatAmount(amount, options = {}) {
|
|
878
917
|
if (!Number.isFinite(amount)) return "";
|
|
@@ -907,17 +946,38 @@ function roundMeasured(value) {
|
|
|
907
946
|
if (value >= 1) return roundTo(value, 0.5);
|
|
908
947
|
return Math.round(value * 100) / 100;
|
|
909
948
|
}
|
|
910
|
-
|
|
911
|
-
|
|
949
|
+
var SMALLEST_USABLE_FRACTION = 0.25;
|
|
950
|
+
function roundCountable(value, allowHalves, ceiling) {
|
|
951
|
+
if (value <= 0) return { value: 0, clamped: false };
|
|
912
952
|
if (value < 1) {
|
|
913
|
-
const
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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;
|
|
917
959
|
}
|
|
918
|
-
return
|
|
960
|
+
return {
|
|
961
|
+
value: Math.round(closest * 100) / 100,
|
|
962
|
+
clamped: value < SMALLEST_USABLE_FRACTION
|
|
963
|
+
};
|
|
919
964
|
}
|
|
920
|
-
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
|
+
};
|
|
921
981
|
}
|
|
922
982
|
function agreeWithAmount(item, amount) {
|
|
923
983
|
if (!item) return item;
|
|
@@ -935,6 +995,12 @@ function agreeWithAmount(item, amount) {
|
|
|
935
995
|
}
|
|
936
996
|
return words.join(" ");
|
|
937
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
|
+
}
|
|
938
1004
|
function scaleIngredient(line, options) {
|
|
939
1005
|
const { factor } = options;
|
|
940
1006
|
const parsed = parseIngredient(line);
|
|
@@ -945,6 +1011,7 @@ function scaleIngredient(line, options) {
|
|
|
945
1011
|
amount: null,
|
|
946
1012
|
unit: null,
|
|
947
1013
|
scaling: "unscaled",
|
|
1014
|
+
adjusted: false,
|
|
948
1015
|
note: "No quantity given; adjust to taste."
|
|
949
1016
|
};
|
|
950
1017
|
}
|
|
@@ -956,41 +1023,47 @@ function scaleIngredient(line, options) {
|
|
|
956
1023
|
amount: parsed.amount,
|
|
957
1024
|
unit: parsed.unit?.canonical ?? null,
|
|
958
1025
|
scaling: "unscaled",
|
|
1026
|
+
adjusted: false,
|
|
959
1027
|
note: "Measure is approximate by nature; adjust to taste."
|
|
960
1028
|
};
|
|
961
1029
|
}
|
|
962
|
-
const
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
unit = converted.unit;
|
|
970
|
-
scaling = "scaled";
|
|
971
|
-
} 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
|
+
}
|
|
972
1037
|
const allowHalves = /cuillère|verre|tasse|bol/.test(parsed.unit?.canonical ?? "");
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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";
|
|
979
1047
|
const unitLabel = unit ? ` ${formatUnit(unit, amount)}` : "";
|
|
980
|
-
const separator = unit ? " de " : " ";
|
|
981
1048
|
const itemText = unit ? parsed.item : agreeWithAmount(parsed.item, amount);
|
|
982
|
-
const item = itemText ?
|
|
983
|
-
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)}`;
|
|
984
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);
|
|
985
1055
|
const result = {
|
|
986
1056
|
original: parsed.original,
|
|
987
1057
|
text,
|
|
988
1058
|
amount,
|
|
989
1059
|
unit: unit?.canonical ?? null,
|
|
990
|
-
scaling
|
|
1060
|
+
scaling,
|
|
1061
|
+
adjusted
|
|
991
1062
|
};
|
|
992
|
-
if (
|
|
993
|
-
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)}.`;
|
|
994
1067
|
}
|
|
995
1068
|
return result;
|
|
996
1069
|
}
|
|
@@ -1005,7 +1078,8 @@ function passthroughIngredients(lines) {
|
|
|
1005
1078
|
text: parsed.original,
|
|
1006
1079
|
amount: parsed.amount,
|
|
1007
1080
|
unit: parsed.unit?.canonical ?? null,
|
|
1008
|
-
scaling: "unscaled"
|
|
1081
|
+
scaling: "unscaled",
|
|
1082
|
+
adjusted: false
|
|
1009
1083
|
};
|
|
1010
1084
|
});
|
|
1011
1085
|
}
|
|
@@ -1023,11 +1097,19 @@ var scaledIngredientSchema = z.object({
|
|
|
1023
1097
|
scaling: z.enum(["scaled", "rounded", "unscaled"]).describe(
|
|
1024
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."
|
|
1025
1099
|
),
|
|
1026
|
-
|
|
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.")
|
|
1027
1104
|
});
|
|
1028
|
-
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));
|
|
1029
1109
|
return {
|
|
1030
|
-
content: [{ type: "text", text:
|
|
1110
|
+
content: [{ type: "text", text: trailer ? `${body}
|
|
1111
|
+
|
|
1112
|
+
${trailer}` : body }],
|
|
1031
1113
|
structuredContent: structured
|
|
1032
1114
|
};
|
|
1033
1115
|
}
|
|
@@ -1119,8 +1201,21 @@ async function runGetRecipe(client, args) {
|
|
|
1119
1201
|
notes.push(
|
|
1120
1202
|
`Quantities rescaled from ${originalCount} to ${args.servings} ${unit} (factor ${Math.round(factor * 100) / 100}).`
|
|
1121
1203
|
);
|
|
1122
|
-
|
|
1123
|
-
|
|
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
|
+
);
|
|
1124
1219
|
}
|
|
1125
1220
|
if (ingredients.some((entry) => entry.scaling === "unscaled")) {
|
|
1126
1221
|
notes.push("Some ingredients carry no usable quantity and were left as published.");
|
|
@@ -1173,14 +1268,20 @@ async function runGetRecipe(client, args) {
|
|
|
1173
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"}.`,
|
|
1174
1269
|
times
|
|
1175
1270
|
].filter(Boolean).join("\n");
|
|
1271
|
+
const rescaled = args.servings !== void 0 && factor !== null;
|
|
1176
1272
|
const ingredientLines = ingredients.map((entry) => {
|
|
1177
|
-
const flag = entry.scaling === "unscaled" ? " (non ajust\xE9)" : "";
|
|
1273
|
+
const flag = rescaled && entry.scaling === "unscaled" ? " (non ajust\xE9)" : "";
|
|
1178
1274
|
return `- ${entry.text}${flag}`;
|
|
1179
1275
|
}).join("\n");
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
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);
|
|
1184
1285
|
} catch (error) {
|
|
1185
1286
|
return toToolError(error);
|
|
1186
1287
|
}
|
|
@@ -1206,15 +1307,26 @@ var scaleIngredientsOutputShape = {
|
|
|
1206
1307
|
factor: z3.number(),
|
|
1207
1308
|
ingredients: z3.array(scaledIngredientSchema),
|
|
1208
1309
|
scaled_count: z3.number().int(),
|
|
1209
|
-
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
|
+
),
|
|
1210
1313
|
unscaled_count: z3.number().int(),
|
|
1211
1314
|
notes: z3.array(z3.string())
|
|
1212
1315
|
};
|
|
1316
|
+
function formatFactor(factor) {
|
|
1317
|
+
return String(Number(factor.toPrecision(3)));
|
|
1318
|
+
}
|
|
1213
1319
|
function runScaleIngredients(args) {
|
|
1214
1320
|
try {
|
|
1215
1321
|
let factor;
|
|
1322
|
+
const notes = [];
|
|
1216
1323
|
if (args.factor !== void 0) {
|
|
1217
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
|
+
}
|
|
1218
1330
|
} else if (args.from_servings !== void 0 && args.to_servings !== void 0) {
|
|
1219
1331
|
factor = args.to_servings / args.from_servings;
|
|
1220
1332
|
} else {
|
|
@@ -1226,10 +1338,12 @@ function runScaleIngredients(args) {
|
|
|
1226
1338
|
const ingredients = scaleIngredients(args.ingredients, { factor });
|
|
1227
1339
|
const counts = {
|
|
1228
1340
|
scaled: ingredients.filter((entry) => entry.scaling === "scaled").length,
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
|
1231
1346
|
};
|
|
1232
|
-
const notes = [];
|
|
1233
1347
|
if (counts.rounded > 0) {
|
|
1234
1348
|
notes.push(
|
|
1235
1349
|
`${counts.rounded} quantity(ies) were rounded to stay usable, rather than left as fractions.`
|
|
@@ -1240,8 +1354,13 @@ function runScaleIngredients(args) {
|
|
|
1240
1354
|
`${counts.unscaled} line(s) carry no usable quantity and were returned unchanged; adjust to taste.`
|
|
1241
1355
|
);
|
|
1242
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
|
+
}
|
|
1243
1362
|
const structured = {
|
|
1244
|
-
factor:
|
|
1363
|
+
factor: Number(factor.toPrecision(3)),
|
|
1245
1364
|
ingredients,
|
|
1246
1365
|
scaled_count: counts.scaled,
|
|
1247
1366
|
rounded_count: counts.rounded,
|
|
@@ -1252,8 +1371,8 @@ function runScaleIngredients(args) {
|
|
|
1252
1371
|
const flag = entry.scaling === "unscaled" ? " (non ajust\xE9)" : "";
|
|
1253
1372
|
return `- ${entry.text}${flag}`;
|
|
1254
1373
|
}).join("\n");
|
|
1255
|
-
return ok(structured, `Facteur ${
|
|
1256
|
-
${lines}
|
|
1374
|
+
return ok(structured, `Facteur ${formatFactor(factor)}:
|
|
1375
|
+
${lines}`, notes);
|
|
1257
1376
|
} catch (error) {
|
|
1258
1377
|
return toToolError(error);
|
|
1259
1378
|
}
|