mcp-marmiton 1.3.0 → 1.5.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 +76 -10
- package/dist/index.js +274 -64
- 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 +2 -2
- package/server.json +5 -6
package/README.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
[](https://github.com/smeet666/mcp-marmiton/actions/workflows/ci.yml)
|
|
5
5
|
[](./LICENSE)
|
|
6
6
|
[](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.smeet666/mcp-marmiton)
|
|
7
|
+
[](https://glama.ai/mcp/servers/smeet666/mcp-marmiton)
|
|
7
8
|
|
|
8
9
|
An [MCP](https://modelcontextprotocol.io) server for [Marmiton](https://www.marmiton.org),
|
|
9
10
|
the French recipe site. Search recipes, read their ingredients and steps, and
|
|
@@ -40,6 +41,14 @@ claude mcp add marmiton -- npx -y mcp-marmiton
|
|
|
40
41
|
}
|
|
41
42
|
```
|
|
42
43
|
|
|
44
|
+
**Bundle, without npm**
|
|
45
|
+
|
|
46
|
+
Download `mcp-marmiton-<version>.mcpb` from
|
|
47
|
+
[the latest release](https://github.com/smeet666/mcp-marmiton/releases/latest) and open
|
|
48
|
+
it. A client that supports MCP bundles installs it on its own, with no npm and
|
|
49
|
+
no configuration file to edit. The bundle carries its dependencies, so nothing
|
|
50
|
+
is fetched at install time.
|
|
51
|
+
|
|
43
52
|
## Tools
|
|
44
53
|
|
|
45
54
|
| Tool | What it does | Key parameters |
|
|
@@ -60,20 +69,44 @@ Asking a language model to divide a recipe by 1.5 tends to produce "2.4 eggs" an
|
|
|
60
69
|
server does the arithmetic itself and, more importantly, **says what it could not
|
|
61
70
|
compute**. Every ingredient comes back with a `scaling` flag:
|
|
62
71
|
|
|
63
|
-
| Flag | Meaning
|
|
64
|
-
| ---------- |
|
|
65
|
-
| `scaled` |
|
|
66
|
-
| `rounded` |
|
|
67
|
-
| `unscaled` |
|
|
72
|
+
| Flag | Meaning | Example |
|
|
73
|
+
| ---------- | -------------------------------------------------- | ---------------------------------------- |
|
|
74
|
+
| `scaled` | The value is the product itself. | `3 oeufs` ×2 → `6 oeufs` |
|
|
75
|
+
| `rounded` | The value had to be moved to stay usable. | `25 cl de lait` ×0.667 → `17 cl de lait` |
|
|
76
|
+
| `unscaled` | Carries no quantity, so left exactly as published. | `sel`, `coriandre` |
|
|
77
|
+
|
|
78
|
+
A count reaches an exact product as readily as a mass does: one pinch multiplied
|
|
79
|
+
by six is six pinches, and that line is `scaled`. `rounded` is for a value that
|
|
80
|
+
landed somewhere the arithmetic did not put it, a half egg taken to a whole one
|
|
81
|
+
or 133.4 g written as 135 g.
|
|
68
82
|
|
|
69
83
|
Two rules are enforced. Scaling a recipe **down never asks for more** than the
|
|
70
84
|
original: half a sachet at factor 0.667 becomes a third of a sachet, never a whole
|
|
71
85
|
one. And scaling never **silently drops** an ingredient by rounding it to zero,
|
|
72
86
|
which is why small amounts come back as fractions.
|
|
73
87
|
|
|
88
|
+
Approximate measures are quantities too. A pinch, a handful, a bouchon or a
|
|
89
|
+
ramequin has the size the cook gives it, and the recipe's proportion lives in how
|
|
90
|
+
many are asked for, so the count is multiplied in whole units: `une pincée de
|
|
91
|
+
bicarbonate de soude` from 6 servings to 25 comes back as `4 pincées de
|
|
92
|
+
bicarbonate de soude`, carrying a note. Nothing is ever
|
|
93
|
+
converted into grams or spoons, where published equivalences span a fourfold
|
|
94
|
+
range; an order of magnitude belongs in a note, never in the quantity.
|
|
95
|
+
|
|
74
96
|
Ranges are read as one quantity: "2 à 3 gousses" doubled reads "4 à 6 gousses",
|
|
75
97
|
and `amount` reports the upper bound, since that is what a cook buys.
|
|
76
98
|
|
|
99
|
+
A line that writes an article where a digit would go is read as one of the
|
|
100
|
+
measure that follows it: `un bouchon de rhum` scaled sixfold is
|
|
101
|
+
`6 bouchons de rhum`, and the note says which word the figure came from. A noun
|
|
102
|
+
sitting between the amount and the `de` that introduces what is measured names a
|
|
103
|
+
container or a gesture, which is how a measure the vocabulary has never met is
|
|
104
|
+
still read. An article before a bare countable thing, as in `un oignon`, leaves
|
|
105
|
+
the line as published.
|
|
106
|
+
|
|
107
|
+
Each ingredient also carries `adjusted`, which says whether rounding moved the
|
|
108
|
+
number away from the exact product.
|
|
109
|
+
|
|
77
110
|
Below a quarter there is nothing a kitchen can measure, so the amount is clamped
|
|
78
111
|
up and **the line stops holding its share of the recipe**. Half a sachet of
|
|
79
112
|
baking powder against three pots of flour comes out four times too strong that
|
|
@@ -220,6 +253,14 @@ claude mcp add marmiton -- npx -y mcp-marmiton
|
|
|
220
253
|
}
|
|
221
254
|
```
|
|
222
255
|
|
|
256
|
+
**Bundle, sans npm**
|
|
257
|
+
|
|
258
|
+
Téléchargez `mcp-marmiton-<version>.mcpb` depuis
|
|
259
|
+
[la dernière release](https://github.com/smeet666/mcp-marmiton/releases/latest) et
|
|
260
|
+
ouvrez-le. Un client compatible avec les bundles MCP l'installe seul, sans npm
|
|
261
|
+
ni fichier de configuration à modifier. Le bundle embarque ses dépendances,
|
|
262
|
+
donc rien n'est téléchargé à l'installation.
|
|
263
|
+
|
|
223
264
|
## Outils
|
|
224
265
|
|
|
225
266
|
| Outil | Rôle | Paramètres principaux |
|
|
@@ -240,21 +281,46 @@ et « 0,67 pincée de sel », énoncés avec le même aplomb qu'un chiffre juste
|
|
|
240
281
|
serveur fait le calcul lui-même et, surtout, **signale ce qu'il n'a pas pu
|
|
241
282
|
calculer**. Chaque ingrédient porte un indicateur `scaling` :
|
|
242
283
|
|
|
243
|
-
| Indicateur | Sens
|
|
244
|
-
| ---------- |
|
|
245
|
-
| `scaled` |
|
|
246
|
-
| `rounded` |
|
|
247
|
-
| `unscaled` |
|
|
284
|
+
| Indicateur | Sens | Exemple |
|
|
285
|
+
| ---------- | ---------------------------------------------------- | ---------------------------------------- |
|
|
286
|
+
| `scaled` | La valeur est le produit exact. | `3 oeufs` ×2 → `6 oeufs` |
|
|
287
|
+
| `rounded` | La valeur a dû être déplacée pour rester utilisable. | `25 cl de lait` ×0,667 → `17 cl de lait` |
|
|
288
|
+
| `unscaled` | Ne porte aucune quantité, laissé tel que publié. | `sel`, `coriandre` |
|
|
289
|
+
|
|
290
|
+
Un décompte tombe juste aussi bien qu'une masse : une pincée multipliée par six
|
|
291
|
+
fait six pincées, et cette ligne est `scaled`. `rounded` désigne une valeur posée
|
|
292
|
+
ailleurs que là où le calcul la mettait, un demi-œuf ramené à un œuf entier ou
|
|
293
|
+
133,4 g écrits 135 g.
|
|
248
294
|
|
|
249
295
|
Deux règles sont garanties. Réduire une recette **ne demande jamais davantage** que
|
|
250
296
|
l'originale : un demi-sachet au facteur 0,667 devient un tiers de sachet, jamais un
|
|
251
297
|
sachet entier. Et l'adaptation ne **supprime jamais** un ingrédient en l'arrondissant
|
|
252
298
|
à zéro, d'où les fractions pour les petites quantités.
|
|
253
299
|
|
|
300
|
+
Les mesures approximatives sont des quantités elles aussi. Une pincée, une
|
|
301
|
+
poignée, un bouchon ou un ramequin ont la taille que le cuisinier leur donne,
|
|
302
|
+
et la proportion de la recette tient au nombre demandé : ce nombre est donc
|
|
303
|
+
multiplié, en unités entières. « une pincée de bicarbonate de soude » de 6 à 25
|
|
304
|
+
parts revient en « 4 pincées de bicarbonate de soude », accompagnée d'une
|
|
305
|
+
note. Aucune conversion en grammes ni en cuillères : les
|
|
306
|
+
équivalences publiées varient du simple au quadruple, et un ordre de grandeur a
|
|
307
|
+
sa place dans une note, jamais dans la quantité.
|
|
308
|
+
|
|
254
309
|
Les fourchettes sont lues comme une seule quantité : « 2 à 3 gousses » doublé
|
|
255
310
|
donne « 4 à 6 gousses », et `amount` renvoie la borne haute, celle que l'on
|
|
256
311
|
achète.
|
|
257
312
|
|
|
313
|
+
Une ligne qui écrit un article là où irait un chiffre est lue comme une unité de
|
|
314
|
+
la mesure qui suit : « un bouchon de rhum » multiplié par six donne « 6 bouchons
|
|
315
|
+
de rhum », et la note indique de quel mot vient le chiffre. Un nom placé entre la
|
|
316
|
+
quantité et le « de » qui introduit ce qui est mesuré désigne un contenant ou un
|
|
317
|
+
geste, ce qui permet de lire une mesure absente du vocabulaire. Un article devant
|
|
318
|
+
une chose dénombrable seule, comme « un oignon », laisse la ligne telle que
|
|
319
|
+
publiée.
|
|
320
|
+
|
|
321
|
+
Chaque ingrédient porte aussi `adjusted`, qui dit si l'arrondi a déplacé le
|
|
322
|
+
nombre par rapport au produit exact.
|
|
323
|
+
|
|
258
324
|
Sous un quart, il n'y a plus rien qu'une cuisine puisse mesurer : la quantité est
|
|
259
325
|
alors remontée et **la ligne cesse de tenir sa part de la recette**. Un demi-sachet
|
|
260
326
|
de levure face à trois pots de farine ressort ainsi quatre fois trop dosé, donc
|
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.5.0";
|
|
8
8
|
var REPO_URL = "https://github.com/smeet666/mcp-marmiton";
|
|
9
9
|
|
|
10
10
|
// src/config.ts
|
|
@@ -752,7 +752,22 @@ var UNITS = {
|
|
|
752
752
|
feuilles: { canonical: "feuille", kind: "portioned" },
|
|
753
753
|
branche: { canonical: "branche", kind: "portioned" },
|
|
754
754
|
branches: { canonical: "branche", kind: "portioned" },
|
|
755
|
-
//
|
|
755
|
+
// Approximate by nature: the size of one is the cook's, the count is the
|
|
756
|
+
// recipe's. See `readPartitiveMeasure` for what puts a word here.
|
|
757
|
+
bouchon: { canonical: "bouchon", kind: "vague" },
|
|
758
|
+
bouchons: { canonical: "bouchon", kind: "vague" },
|
|
759
|
+
larme: { canonical: "larme", kind: "vague" },
|
|
760
|
+
larmes: { canonical: "larme", kind: "vague" },
|
|
761
|
+
doigt: { canonical: "doigt", kind: "vague" },
|
|
762
|
+
doigts: { canonical: "doigt", kind: "vague" },
|
|
763
|
+
nuage: { canonical: "nuage", kind: "vague" },
|
|
764
|
+
nuages: { canonical: "nuage", kind: "vague" },
|
|
765
|
+
louche: { canonical: "louche", kind: "vague" },
|
|
766
|
+
louches: { canonical: "louche", kind: "vague" },
|
|
767
|
+
lichette: { canonical: "lichette", kind: "vague" },
|
|
768
|
+
lichettes: { canonical: "lichette", kind: "vague" },
|
|
769
|
+
pointe: { canonical: "pointe", kind: "vague" },
|
|
770
|
+
pointes: { canonical: "pointe", kind: "vague" },
|
|
756
771
|
pincee: { canonical: "pinc\xE9e", kind: "vague", plural: "pinc\xE9es" },
|
|
757
772
|
pincees: { canonical: "pinc\xE9e", kind: "vague", plural: "pinc\xE9es" },
|
|
758
773
|
trait: { canonical: "trait", kind: "vague" },
|
|
@@ -762,7 +777,11 @@ var UNITS = {
|
|
|
762
777
|
goutte: { canonical: "goutte", kind: "vague", plural: "gouttes" },
|
|
763
778
|
gouttes: { canonical: "goutte", kind: "vague", plural: "gouttes" },
|
|
764
779
|
poignee: { canonical: "poign\xE9e", kind: "vague", plural: "poign\xE9es" },
|
|
765
|
-
poignees: { canonical: "poign\xE9e", kind: "vague", plural: "poign\xE9es" }
|
|
780
|
+
poignees: { canonical: "poign\xE9e", kind: "vague", plural: "poign\xE9es" },
|
|
781
|
+
// "noix" carries its own plural mark already.
|
|
782
|
+
noix: { canonical: "noix", kind: "vague", plural: "noix" },
|
|
783
|
+
soupcon: { canonical: "soup\xE7on", kind: "vague", plural: "soup\xE7ons" },
|
|
784
|
+
soupcons: { canonical: "soup\xE7on", kind: "vague", plural: "soup\xE7ons" }
|
|
766
785
|
};
|
|
767
786
|
function normalizeUnitKey(text) {
|
|
768
787
|
return text.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/\./g, " ").replace(/\s+/g, " ").trim();
|
|
@@ -771,6 +790,48 @@ var UNIT_KEYS = Object.keys(UNITS).sort((a, b) => b.length - a.length);
|
|
|
771
790
|
function lookupUnit(text) {
|
|
772
791
|
return UNITS[normalizeUnitKey(text)] ?? null;
|
|
773
792
|
}
|
|
793
|
+
var NOT_A_MEASURE = /* @__PURE__ */ new Set([
|
|
794
|
+
"peu",
|
|
795
|
+
"beaucoup",
|
|
796
|
+
"plus",
|
|
797
|
+
"moins",
|
|
798
|
+
"assez",
|
|
799
|
+
"trop",
|
|
800
|
+
"autant",
|
|
801
|
+
"tant",
|
|
802
|
+
"moitie",
|
|
803
|
+
"quart",
|
|
804
|
+
"tiers",
|
|
805
|
+
"reste",
|
|
806
|
+
"melange",
|
|
807
|
+
"ensemble"
|
|
808
|
+
]);
|
|
809
|
+
function readPartitiveMeasure(text) {
|
|
810
|
+
const match = new RegExp("^\\s*(\\p{L}+)\\s+(?=(?:de|du|des)\\s|d')", "u").exec(text);
|
|
811
|
+
if (!match) return null;
|
|
812
|
+
const word = match[1];
|
|
813
|
+
if (word.length < 3) return null;
|
|
814
|
+
if (NOT_A_MEASURE.has(normalizeUnitKey(word))) return null;
|
|
815
|
+
if (lookupUnit(word)) return null;
|
|
816
|
+
const canonical = frenchSingular(word);
|
|
817
|
+
return {
|
|
818
|
+
unit: { canonical, kind: "vague", plural: frenchPlural(canonical) },
|
|
819
|
+
rest: text.slice(match[0].length)
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
function frenchSingular(word) {
|
|
823
|
+
if (/eaux$/i.test(word)) return word.slice(0, -1);
|
|
824
|
+
if (/aux$/i.test(word)) return `${word.slice(0, -3)}al`;
|
|
825
|
+
if (/[aiou]s$/i.test(word)) return word;
|
|
826
|
+
if (/s$/i.test(word) && word.length > 3) return word.slice(0, -1);
|
|
827
|
+
return word;
|
|
828
|
+
}
|
|
829
|
+
function frenchPlural(word) {
|
|
830
|
+
if (/[sxz]$/i.test(word)) return word;
|
|
831
|
+
if (/eau$/i.test(word)) return `${word}x`;
|
|
832
|
+
if (/al$/i.test(word)) return `${word.slice(0, -2)}aux`;
|
|
833
|
+
return `${word}s`;
|
|
834
|
+
}
|
|
774
835
|
var PROMOTIONS = {
|
|
775
836
|
mg: { to: "g", per: 1e3 },
|
|
776
837
|
g: { to: "kg", per: 1e3 },
|
|
@@ -791,21 +852,21 @@ function demoteUnit(unit) {
|
|
|
791
852
|
const target = lookupUnit(step.to);
|
|
792
853
|
return target ? { unit: target, per: step.per } : null;
|
|
793
854
|
}
|
|
794
|
-
function
|
|
855
|
+
function readableUnitStep(unit, amount) {
|
|
795
856
|
if (unit.kind !== "measured" || !Number.isFinite(amount) || amount <= 0) {
|
|
796
|
-
return {
|
|
857
|
+
return { unit, ratio: 1 };
|
|
797
858
|
}
|
|
798
859
|
const up = PROMOTIONS[unit.canonical];
|
|
799
860
|
if (up && amount >= up.per) {
|
|
800
861
|
const target = lookupUnit(up.to);
|
|
801
|
-
if (target) return {
|
|
862
|
+
if (target) return { unit: target, ratio: 1 / up.per };
|
|
802
863
|
}
|
|
803
864
|
const down = DEMOTIONS[unit.canonical];
|
|
804
865
|
if (down && amount < 1) {
|
|
805
866
|
const target = lookupUnit(down.to);
|
|
806
|
-
if (target) return {
|
|
867
|
+
if (target) return { unit: target, ratio: down.per };
|
|
807
868
|
}
|
|
808
|
-
return {
|
|
869
|
+
return { unit, ratio: 1 };
|
|
809
870
|
}
|
|
810
871
|
function formatUnit(unit, amount) {
|
|
811
872
|
if (unit.symbol) return unit.canonical;
|
|
@@ -857,11 +918,53 @@ function parseLeadingQuantity(text) {
|
|
|
857
918
|
}
|
|
858
919
|
return null;
|
|
859
920
|
}
|
|
921
|
+
var ARTICLE_AMOUNTS = {
|
|
922
|
+
un: 1,
|
|
923
|
+
une: 1,
|
|
924
|
+
quelques: 3
|
|
925
|
+
};
|
|
926
|
+
function parseLeadingArticle(text) {
|
|
927
|
+
const match = /^\s*(un|une|quelques)\b\s*/i.exec(text);
|
|
928
|
+
if (!match) return null;
|
|
929
|
+
const rest = text.slice(match[0].length);
|
|
930
|
+
if (!matchLeadingUnit(rest, true)) return null;
|
|
931
|
+
const word = match[1];
|
|
932
|
+
return {
|
|
933
|
+
amount: ARTICLE_AMOUNTS[word.toLowerCase()],
|
|
934
|
+
length: match[0].length,
|
|
935
|
+
word
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
function matchLeadingUnit(text, partitive = false) {
|
|
939
|
+
const normalized = normalizeUnitKey(text);
|
|
940
|
+
for (const key of UNIT_KEYS) {
|
|
941
|
+
if (normalized !== key && !normalized.startsWith(`${key} `)) continue;
|
|
942
|
+
const unit = lookupUnit(key);
|
|
943
|
+
if (!unit) continue;
|
|
944
|
+
const wordCount = key.split(" ").length;
|
|
945
|
+
const words = text.trim().split(/\s+/);
|
|
946
|
+
return {
|
|
947
|
+
unit,
|
|
948
|
+
unitText: words.slice(0, wordCount).join(" "),
|
|
949
|
+
rest: words.slice(wordCount).join(" ")
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
const measure = partitive ? readPartitiveMeasure(text) : null;
|
|
953
|
+
if (measure) {
|
|
954
|
+
return {
|
|
955
|
+
unit: measure.unit,
|
|
956
|
+
unitText: text.trim().split(/\s+/)[0],
|
|
957
|
+
rest: measure.rest
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
return null;
|
|
961
|
+
}
|
|
860
962
|
function parseIngredient(line) {
|
|
861
963
|
const original = line;
|
|
862
964
|
const text = line.trim();
|
|
863
965
|
const range = parseLeadingRange(text);
|
|
864
|
-
const
|
|
966
|
+
const article = range ? null : parseLeadingArticle(text);
|
|
967
|
+
const quantity = range ?? parseLeadingQuantity(text) ?? article;
|
|
865
968
|
if (!quantity) {
|
|
866
969
|
return {
|
|
867
970
|
original,
|
|
@@ -870,24 +973,16 @@ function parseIngredient(line) {
|
|
|
870
973
|
rangeSeparator: null,
|
|
871
974
|
unit: null,
|
|
872
975
|
unitText: null,
|
|
873
|
-
item: text
|
|
976
|
+
item: text,
|
|
977
|
+
articleWord: null
|
|
874
978
|
};
|
|
875
979
|
}
|
|
876
980
|
let rest = text.slice(quantity.length).trimStart();
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
const
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
unit = lookupUnit(key);
|
|
883
|
-
const wordCount = key.split(" ").length;
|
|
884
|
-
const words = rest.split(/\s+/);
|
|
885
|
-
unitText = words.slice(0, wordCount).join(" ");
|
|
886
|
-
rest = words.slice(wordCount).join(" ");
|
|
887
|
-
break;
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
const item = rest.replace(/^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+)/i, "").trim();
|
|
981
|
+
const matched = matchLeadingUnit(rest, quantity === article);
|
|
982
|
+
const unit = matched?.unit ?? null;
|
|
983
|
+
const unitText = matched?.unitText ?? null;
|
|
984
|
+
if (matched) rest = matched.rest;
|
|
985
|
+
const item = rest.replace(/^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+)/i, "").replace(/^(?:une|un)\s+/i, "").trim();
|
|
891
986
|
return {
|
|
892
987
|
original,
|
|
893
988
|
amount: quantity.amount,
|
|
@@ -895,7 +990,8 @@ function parseIngredient(line) {
|
|
|
895
990
|
rangeSeparator: range?.separator ?? null,
|
|
896
991
|
unit,
|
|
897
992
|
unitText,
|
|
898
|
-
item
|
|
993
|
+
item,
|
|
994
|
+
articleWord: quantity === article ? article?.word ?? null : null
|
|
899
995
|
};
|
|
900
996
|
}
|
|
901
997
|
function parseLeadingRange(text) {
|
|
@@ -948,6 +1044,7 @@ function roundMeasured(value) {
|
|
|
948
1044
|
return Math.round(value * 100) / 100;
|
|
949
1045
|
}
|
|
950
1046
|
var SMALLEST_USABLE_FRACTION = 0.25;
|
|
1047
|
+
var EXACT_WITHIN = 0.01;
|
|
951
1048
|
function roundCountable(value, allowHalves, ceiling) {
|
|
952
1049
|
if (value <= 0) return { value: 0, clamped: false };
|
|
953
1050
|
if (value < 1) {
|
|
@@ -965,19 +1062,28 @@ function roundCountable(value, allowHalves, ceiling) {
|
|
|
965
1062
|
}
|
|
966
1063
|
return { value: allowHalves ? roundTo(value, 0.5) : Math.round(value), clamped: false };
|
|
967
1064
|
}
|
|
1065
|
+
function scaleApproximateCount(source, raw, factor) {
|
|
1066
|
+
if (raw <= 0) return 0;
|
|
1067
|
+
const whole = Math.max(1, Math.round(raw));
|
|
1068
|
+
return factor < 1 ? Math.min(whole, source) : Math.max(whole, source);
|
|
1069
|
+
}
|
|
968
1070
|
function roundMeasuredInUsableUnit(unit, raw) {
|
|
969
1071
|
let current = unit;
|
|
970
1072
|
let value = raw;
|
|
971
1073
|
while (value > 0 && value < 1) {
|
|
972
|
-
const
|
|
973
|
-
if (!
|
|
974
|
-
value *=
|
|
975
|
-
current =
|
|
1074
|
+
const step2 = demoteUnit(current);
|
|
1075
|
+
if (!step2) break;
|
|
1076
|
+
value *= step2.per;
|
|
1077
|
+
current = step2.unit;
|
|
976
1078
|
}
|
|
977
1079
|
const rounded = roundMeasured(value);
|
|
1080
|
+
const amount = rounded === 0 && value > 0 ? Number(value.toPrecision(2)) : rounded;
|
|
1081
|
+
const step = readableUnitStep(current, amount);
|
|
1082
|
+
if (step.ratio === 1) return { amount, unit: current, exact: value };
|
|
978
1083
|
return {
|
|
979
|
-
amount:
|
|
980
|
-
unit:
|
|
1084
|
+
amount: Math.round(amount * step.ratio * 100) / 100,
|
|
1085
|
+
unit: step.unit,
|
|
1086
|
+
exact: value * step.ratio
|
|
981
1087
|
};
|
|
982
1088
|
}
|
|
983
1089
|
function agreeWithAmount(item, amount) {
|
|
@@ -986,16 +1092,72 @@ function agreeWithAmount(item, amount) {
|
|
|
986
1092
|
const head = words[0] ?? "";
|
|
987
1093
|
if (head.length <= 3) return item;
|
|
988
1094
|
const wantsPlural = amount >= 2;
|
|
989
|
-
const isPlural = /s$/i.test(head);
|
|
1095
|
+
const isPlural = /s$|eaux$|aux$/i.test(head);
|
|
990
1096
|
if (wantsPlural && !isPlural) {
|
|
991
1097
|
if (/[sxz]$/i.test(head)) return item;
|
|
992
|
-
words[0] = `${head}
|
|
1098
|
+
if (/eau$/i.test(head)) words[0] = `${head}x`;
|
|
1099
|
+
else if (/al$/i.test(head)) words[0] = `${head.slice(0, -2)}aux`;
|
|
1100
|
+
else words[0] = `${head}s`;
|
|
993
1101
|
} else if (!wantsPlural && isPlural) {
|
|
994
|
-
if (/
|
|
995
|
-
words[0] = head.slice(0, -
|
|
1102
|
+
if (/eaux$/i.test(head)) words[0] = head.slice(0, -1);
|
|
1103
|
+
else if (/aux$/i.test(head)) words[0] = `${head.slice(0, -3)}al`;
|
|
1104
|
+
else if (/[aiou]s$/i.test(head)) return item;
|
|
1105
|
+
else words[0] = head.slice(0, -1);
|
|
1106
|
+
}
|
|
1107
|
+
const last = words.length - 1;
|
|
1108
|
+
if (last > 0) {
|
|
1109
|
+
const adjective = agreeTrailingAdjective(words[last], wantsPlural);
|
|
1110
|
+
if (adjective) words[last] = adjective;
|
|
996
1111
|
}
|
|
997
1112
|
return words.join(" ");
|
|
998
1113
|
}
|
|
1114
|
+
var AGREEABLE_ADJECTIVES = /* @__PURE__ */ new Set([
|
|
1115
|
+
"entier",
|
|
1116
|
+
"entiere",
|
|
1117
|
+
"moyen",
|
|
1118
|
+
"moyenne",
|
|
1119
|
+
"petit",
|
|
1120
|
+
"petite",
|
|
1121
|
+
"grand",
|
|
1122
|
+
"grande",
|
|
1123
|
+
"mur",
|
|
1124
|
+
"mure",
|
|
1125
|
+
"vert",
|
|
1126
|
+
"verte",
|
|
1127
|
+
"rouge",
|
|
1128
|
+
"jaune",
|
|
1129
|
+
"noir",
|
|
1130
|
+
"noire",
|
|
1131
|
+
"blanc",
|
|
1132
|
+
"blanche",
|
|
1133
|
+
"rond",
|
|
1134
|
+
"ronde",
|
|
1135
|
+
"hache",
|
|
1136
|
+
"hachee",
|
|
1137
|
+
"coupe",
|
|
1138
|
+
"coupee",
|
|
1139
|
+
"rape",
|
|
1140
|
+
"rapee",
|
|
1141
|
+
"pele",
|
|
1142
|
+
"pelee",
|
|
1143
|
+
"epluche",
|
|
1144
|
+
"epluchee",
|
|
1145
|
+
"denoyaute",
|
|
1146
|
+
"denoyautee",
|
|
1147
|
+
"emince",
|
|
1148
|
+
"emincee"
|
|
1149
|
+
]);
|
|
1150
|
+
function foldWord(word) {
|
|
1151
|
+
return word.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
|
|
1152
|
+
}
|
|
1153
|
+
function agreeTrailingAdjective(word, wantsPlural) {
|
|
1154
|
+
const folded = foldWord(word);
|
|
1155
|
+
const isPlural = folded.endsWith("s");
|
|
1156
|
+
const singular = isPlural ? word.slice(0, -1) : word;
|
|
1157
|
+
if (!AGREEABLE_ADJECTIVES.has(foldWord(singular))) return null;
|
|
1158
|
+
if (wantsPlural === isPlural) return null;
|
|
1159
|
+
return wantsPlural ? `${word}s` : singular;
|
|
1160
|
+
}
|
|
999
1161
|
var MUTE_H_WORDS = /^(?:huile|huiles|huitre|huitres|huître|huîtres|herbe|herbes|hysope)\b/i;
|
|
1000
1162
|
function joinItem(item) {
|
|
1001
1163
|
if (!item) return "";
|
|
@@ -1017,34 +1179,42 @@ function scaleIngredient(line, options) {
|
|
|
1017
1179
|
};
|
|
1018
1180
|
}
|
|
1019
1181
|
const kind = parsed.unit?.kind ?? "countable";
|
|
1020
|
-
if (kind === "vague") {
|
|
1021
|
-
return {
|
|
1022
|
-
original: parsed.original,
|
|
1023
|
-
text: parsed.original,
|
|
1024
|
-
amount: parsed.amount,
|
|
1025
|
-
unit: parsed.unit?.canonical ?? null,
|
|
1026
|
-
scaling: "unscaled",
|
|
1027
|
-
adjusted: false,
|
|
1028
|
-
note: "Measure is approximate by nature; adjust to taste."
|
|
1029
|
-
};
|
|
1030
|
-
}
|
|
1031
1182
|
const scaleBound = (source) => {
|
|
1032
1183
|
const raw = source * factor;
|
|
1184
|
+
if (kind === "vague") {
|
|
1185
|
+
return {
|
|
1186
|
+
amount: scaleApproximateCount(source, raw, factor),
|
|
1187
|
+
unit: parsed.unit,
|
|
1188
|
+
exact: raw,
|
|
1189
|
+
raw,
|
|
1190
|
+
clamped: false
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1033
1193
|
if (kind === "measured") {
|
|
1034
1194
|
const converted = roundMeasuredInUsableUnit(parsed.unit, raw);
|
|
1035
|
-
|
|
1036
|
-
|
|
1195
|
+
return {
|
|
1196
|
+
amount: converted.amount,
|
|
1197
|
+
unit: converted.unit,
|
|
1198
|
+
exact: converted.exact,
|
|
1199
|
+
raw,
|
|
1200
|
+
clamped: false
|
|
1201
|
+
};
|
|
1037
1202
|
}
|
|
1038
1203
|
const allowHalves = /cuillère|verre|tasse|bol/.test(parsed.unit?.canonical ?? "");
|
|
1039
1204
|
const ceiling = factor < 1 ? source : Number.POSITIVE_INFINITY;
|
|
1040
1205
|
const rounded = roundCountable(raw, allowHalves, ceiling);
|
|
1041
|
-
return {
|
|
1206
|
+
return {
|
|
1207
|
+
amount: rounded.value,
|
|
1208
|
+
unit: parsed.unit,
|
|
1209
|
+
exact: raw,
|
|
1210
|
+
raw,
|
|
1211
|
+
clamped: rounded.clamped
|
|
1212
|
+
};
|
|
1042
1213
|
};
|
|
1043
1214
|
const low = scaleBound(parsed.amount);
|
|
1044
1215
|
const high = parsed.amountMax === null ? null : scaleBound(parsed.amountMax);
|
|
1045
1216
|
const amount = high?.amount ?? low.amount;
|
|
1046
1217
|
const unit = high?.unit ?? low.unit;
|
|
1047
|
-
const scaling = kind === "measured" ? "scaled" : "rounded";
|
|
1048
1218
|
const unitLabel = unit ? ` ${formatUnit(unit, amount)}` : "";
|
|
1049
1219
|
const itemText = unit ? parsed.item : agreeWithAmount(parsed.item, amount);
|
|
1050
1220
|
const item = unit ? joinItem(parsed.item) : itemText ? ` ${itemText}` : "";
|
|
@@ -1052,7 +1222,9 @@ function scaleIngredient(line, options) {
|
|
|
1052
1222
|
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)}`;
|
|
1053
1223
|
const text = `${amountText}${unitLabel}${item}`.trim();
|
|
1054
1224
|
const bounds = high === null ? [low] : [low, high];
|
|
1055
|
-
const adjusted = bounds.some((bound) => Math.abs(bound.
|
|
1225
|
+
const adjusted = bounds.some((bound) => Math.abs(bound.exact - bound.amount) > EXACT_WITHIN);
|
|
1226
|
+
const clamped = bounds.some((bound) => bound.clamped);
|
|
1227
|
+
const scaling = adjusted || clamped ? "rounded" : "scaled";
|
|
1056
1228
|
const result = {
|
|
1057
1229
|
original: parsed.original,
|
|
1058
1230
|
text,
|
|
@@ -1061,13 +1233,33 @@ function scaleIngredient(line, options) {
|
|
|
1061
1233
|
scaling,
|
|
1062
1234
|
adjusted
|
|
1063
1235
|
};
|
|
1064
|
-
if (
|
|
1236
|
+
if (kind === "vague") {
|
|
1237
|
+
const one = unit ? formatUnit(unit, 1) : "measure";
|
|
1238
|
+
const many = unit ? formatUnit(unit, 2) : "measures";
|
|
1239
|
+
const sentences = [];
|
|
1240
|
+
if (parsed.articleWord) {
|
|
1241
|
+
sentences.push(`"${parsed.articleWord}" read as ${formatAmount(parsed.amount)}.`);
|
|
1242
|
+
}
|
|
1243
|
+
sentences.push(
|
|
1244
|
+
`${APPROXIMATE_MEASURE_MARKER} the number of ${many} was multiplied, and one ${one} keeps the size the cook gives it.`
|
|
1245
|
+
);
|
|
1246
|
+
if (adjusted) sentences.push(`Rounded from ${formatAmount(Math.round(low.raw * 100) / 100)}.`);
|
|
1247
|
+
result.note = sentences.join(" ");
|
|
1248
|
+
} else if (bounds.some((bound) => bound.clamped)) {
|
|
1065
1249
|
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.`;
|
|
1066
|
-
} else if (
|
|
1250
|
+
} else if (adjusted) {
|
|
1067
1251
|
result.note = `Rounded from ${formatAmount(Math.round(low.raw * 100) / 100)}.`;
|
|
1068
1252
|
}
|
|
1253
|
+
if (kind !== "vague" && parsed.articleWord) {
|
|
1254
|
+
const read = `"${parsed.articleWord}" read as ${formatAmount(parsed.amount)}.`;
|
|
1255
|
+
result.note = result.note ? `${read} ${result.note}` : read;
|
|
1256
|
+
}
|
|
1069
1257
|
return result;
|
|
1070
1258
|
}
|
|
1259
|
+
var APPROXIMATE_MEASURE_MARKER = "Approximate measure:";
|
|
1260
|
+
function isApproximateMeasure(entry) {
|
|
1261
|
+
return entry.note?.includes(APPROXIMATE_MEASURE_MARKER) ?? false;
|
|
1262
|
+
}
|
|
1071
1263
|
function scaleIngredients(lines, options) {
|
|
1072
1264
|
return lines.map((line) => scaleIngredient(line, options));
|
|
1073
1265
|
}
|
|
@@ -1096,17 +1288,22 @@ var scaledIngredientSchema = z.object({
|
|
|
1096
1288
|
),
|
|
1097
1289
|
unit: z.string().nullable().describe("The unit 'amount' is in, which may differ from the one the recipe used."),
|
|
1098
1290
|
scaling: z.enum(["scaled", "rounded", "unscaled"]).describe(
|
|
1099
|
-
"'scaled'
|
|
1291
|
+
"'scaled' means the arithmetic came out exact, which a count reaches as readily as a mass: one pinch multiplied by six is six pinches. 'rounded' means the value had to be moved to stay usable, to a whole or half unit or to a step a scale can show. 'unscaled' was left alone, because the line carries no quantity."
|
|
1100
1292
|
),
|
|
1101
1293
|
adjusted: z.boolean().describe(
|
|
1102
|
-
"True when rounding moved the value away from the exact product
|
|
1294
|
+
"True when rounding moved the value away from the exact product, which is what makes a line 'rounded' rather than 'scaled'."
|
|
1103
1295
|
),
|
|
1104
|
-
note: z.string().optional().describe(
|
|
1296
|
+
note: z.string().optional().describe(
|
|
1297
|
+
"Why the line was rounded, clamped or left alone, including when the measure is approximate and only its count was multiplied."
|
|
1298
|
+
)
|
|
1105
1299
|
});
|
|
1300
|
+
function indentMarkerLines(body) {
|
|
1301
|
+
return body.replace(/^(Note:|Source:)/gm, " $1");
|
|
1302
|
+
}
|
|
1106
1303
|
function ok(structured, text, notes = []) {
|
|
1107
1304
|
const trailer = notes.map((note) => `Note: ${note}`).join("\n");
|
|
1108
1305
|
const budget = MAX_TEXT_MIRROR_CHARS - (trailer ? trailer.length + 2 : 0);
|
|
1109
|
-
const body = truncate(text, Math.max(0, budget));
|
|
1306
|
+
const body = truncate(indentMarkerLines(text), Math.max(0, budget));
|
|
1110
1307
|
return {
|
|
1111
1308
|
content: [{ type: "text", text: trailer ? `${body}
|
|
1112
1309
|
|
|
@@ -1129,10 +1326,10 @@ function truncate(text, maxChars) {
|
|
|
1129
1326
|
var getRecipeDescription = [
|
|
1130
1327
|
"Read one Marmiton recipe: ingredients, steps, times, category, rating and nutrition.",
|
|
1131
1328
|
"Give the id returned by search_recipes, or a marmiton.org recipe URL.",
|
|
1132
|
-
"Set 'servings' to rescale the quantities. Each ingredient reports how it was handled: 'scaled'
|
|
1133
|
-
"
|
|
1134
|
-
"
|
|
1135
|
-
"the point of scaling here is to avoid answers like '2.4 eggs'.",
|
|
1329
|
+
"Set 'servings' to rescale the quantities. Each ingredient reports how it was handled: 'scaled' when the",
|
|
1330
|
+
"arithmetic came out exact, which a count of eggs, spoons or pinches reaches as readily as a mass in grams,",
|
|
1331
|
+
"'rounded' when the value had to be moved to stay usable, and 'unscaled' for lines carrying no quantity at all.",
|
|
1332
|
+
"Trust that flag rather than recomputing: the point of scaling here is to avoid answers like '2.4 eggs'.",
|
|
1136
1333
|
"Recipes yielding pieces rather than servings are rescaled the same way; check 'yield' to see which.",
|
|
1137
1334
|
"Always cite 'attribution' when showing a recipe to a user."
|
|
1138
1335
|
].join(" ");
|
|
@@ -1218,6 +1415,11 @@ async function runGetRecipe(client, args) {
|
|
|
1218
1415
|
`${clampedCount} quantity(ies) fell below the smallest amount worth measuring and were clamped up, so their proportions no longer match the published recipe.`
|
|
1219
1416
|
);
|
|
1220
1417
|
}
|
|
1418
|
+
if (ingredients.some(isApproximateMeasure)) {
|
|
1419
|
+
notes.push(
|
|
1420
|
+
"Approximate measures such as a pinch or a handful had their count multiplied; the size of one is yours to judge."
|
|
1421
|
+
);
|
|
1422
|
+
}
|
|
1221
1423
|
if (ingredients.some((entry) => entry.scaling === "unscaled")) {
|
|
1222
1424
|
notes.push("Some ingredients carry no usable quantity and were left as published.");
|
|
1223
1425
|
}
|
|
@@ -1295,8 +1497,10 @@ var scaleIngredientsDescription = [
|
|
|
1295
1497
|
"Give either 'factor' directly, or 'from_servings' and 'to_servings' and the factor is computed.",
|
|
1296
1498
|
"Works on any French ingredient list, whatever its source, so it also serves a recipe the user pasted in.",
|
|
1297
1499
|
"Quantities in grams or millilitres are multiplied and rounded to readable values; countable things such as",
|
|
1298
|
-
"eggs or spoons are rounded to whole or half units;
|
|
1299
|
-
"
|
|
1500
|
+
"eggs or spoons are rounded to whole or half units; approximate measures such as a pinch or a handful have",
|
|
1501
|
+
"their count multiplied in whole units and stay in their own vocabulary. A line writing an article where a",
|
|
1502
|
+
"digit would go, as in 'un bouchon de rhum' or 'une pinc\xE9e de sel', is read as one of that measure. Lines",
|
|
1503
|
+
"carrying no quantity are returned untouched and flagged. Prefer this over doing the arithmetic yourself."
|
|
1300
1504
|
].join(" ");
|
|
1301
1505
|
var scaleIngredientsInputShape = {
|
|
1302
1506
|
ingredients: z3.array(z3.string().max(300)).min(1).max(100).describe("Ingredient lines, for example ['200 g de farine', '3 oeufs', 'sel']."),
|
|
@@ -1343,7 +1547,8 @@ function runScaleIngredients(args) {
|
|
|
1343
1547
|
// the roundable category: at a factor of 100 every egg lands whole.
|
|
1344
1548
|
rounded: ingredients.filter((entry) => entry.scaling === "rounded" && entry.adjusted).length,
|
|
1345
1549
|
unscaled: ingredients.filter((entry) => entry.scaling === "unscaled").length,
|
|
1346
|
-
clamped: ingredients.filter((entry) => /clamped up/i.test(entry.note ?? "")).length
|
|
1550
|
+
clamped: ingredients.filter((entry) => /clamped up/i.test(entry.note ?? "")).length,
|
|
1551
|
+
approximate: ingredients.filter(isApproximateMeasure).length
|
|
1347
1552
|
};
|
|
1348
1553
|
if (counts.rounded > 0) {
|
|
1349
1554
|
notes.push(
|
|
@@ -1355,6 +1560,11 @@ function runScaleIngredients(args) {
|
|
|
1355
1560
|
`${counts.unscaled} line(s) carry no usable quantity and were returned unchanged; adjust to taste.`
|
|
1356
1561
|
);
|
|
1357
1562
|
}
|
|
1563
|
+
if (counts.approximate > 0) {
|
|
1564
|
+
notes.push(
|
|
1565
|
+
`${counts.approximate} approximate measure(s) such as a pinch or a handful had their count multiplied; the size of one is yours to judge.`
|
|
1566
|
+
);
|
|
1567
|
+
}
|
|
1358
1568
|
if (counts.clamped > 0) {
|
|
1359
1569
|
notes.push(
|
|
1360
1570
|
`${counts.clamped} quantity(ies) fell below the smallest amount worth measuring and were clamped up, so their proportions no longer match the original recipe.`
|