Remove bulk ingredient parsing feature and Ollama leftovers
This commit is contained in:
1 parent
9162992415
commit
a8a4eb488f
13 files changed
+26
-746
No files matched your search
@@ -105,12 +105,6 @@ Run the editor:
|
||||
npm run dev:app
|
||||
```
|
||||
|
||||
Ingredient bulk entry uses the local Ollama service through
|
||||
`http://10.0.10.211:11434/api/chat` and the purpose-built
|
||||
`qwen3:4b-instruct` parsing prompt. Override these defaults with
|
||||
`FORMULATION_OLLAMA_URL` and `FORMULATION_INGREDIENT_PARSER_MODEL`. The parser
|
||||
endpoint is disabled whenever `FORMULATION_READ_ONLY=true`.
|
||||
|
||||
Open <http://localhost:4322/app/>.
|
||||
|
||||
To stop any process listening on the application port and start a fresh Astro
|
||||
|
||||
@@ -52,6 +52,5 @@ git diff --check
|
||||
```
|
||||
|
||||
The application supports a read-only deployment with
|
||||
`FORMULATION_READ_ONLY=true`. Ingredient bulk parsing additionally accepts
|
||||
`FORMULATION_OLLAMA_URL` and `FORMULATION_INGREDIENT_PARSER_MODEL`; USDA imports
|
||||
read `USDA_FDC_API_KEY` from the environment.
|
||||
`FORMULATION_READ_ONLY=true`. USDA imports read `USDA_FDC_API_KEY` from the
|
||||
environment.
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { parseIngredientsDeterministic } from "../../../../../lib/ingredient-parser";
|
||||
import { readOnlyMode } from "../../../../../lib/runtime";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
if (readOnlyMode) return Response.json({ error: "Ingredient parsing is unavailable in read-only mode." }, { status: 403 });
|
||||
try {
|
||||
const body = await request.json() as { text?: unknown };
|
||||
if (typeof body.text !== "string") return Response.json({ error: "Ingredient text is required." }, { status: 400 });
|
||||
if (body.text.length > 20_000) return Response.json({ error: "Ingredient text is too long." }, { status: 413 });
|
||||
return Response.json(parseIngredientsDeterministic(body.text));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && error.name === "TimeoutError"
|
||||
? "Ingredient parser timed out. Try again."
|
||||
: error instanceof Error ? error.message : "Unable to parse ingredients.";
|
||||
return Response.json({ error: message }, { status: 502 });
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import {
|
||||
BulkIngredientImportModal,
|
||||
BulkPrepStepsModal,
|
||||
RecipeItemRow,
|
||||
RecipeMethodEditor,
|
||||
@@ -31,13 +30,6 @@ export default function RecipeStructureEditor(props: RecipeEditorProps) {
|
||||
setFocusedItem,
|
||||
draftNotes,
|
||||
setDraftNotes,
|
||||
bulkOpen,
|
||||
setBulkOpen,
|
||||
bulkText,
|
||||
setBulkText,
|
||||
bulkError,
|
||||
setBulkError,
|
||||
bulkParsing,
|
||||
prepOpen,
|
||||
setPrepOpen,
|
||||
prepText,
|
||||
@@ -65,7 +57,6 @@ export default function RecipeStructureEditor(props: RecipeEditorProps) {
|
||||
createPendingRowIngredient,
|
||||
createPendingIngredient,
|
||||
createPendingRecipe,
|
||||
addBulkIngredients,
|
||||
addBulkPrepSteps,
|
||||
unconvertedYieldItems,
|
||||
} = useRecipeStructure(props);
|
||||
@@ -662,32 +653,9 @@ export default function RecipeStructureEditor(props: RecipeEditorProps) {
|
||||
</svg>
|
||||
Add Note
|
||||
</button>
|
||||
<button
|
||||
class="bulk-add-trigger"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBulkError("");
|
||||
setBulkOpen(true);
|
||||
}}
|
||||
>
|
||||
Add Ingredients
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BulkIngredientImportModal
|
||||
open={bulkOpen}
|
||||
text={bulkText}
|
||||
error={bulkError}
|
||||
parsing={bulkParsing}
|
||||
onTextChange={(val) => {
|
||||
setBulkText(val);
|
||||
setBulkError("");
|
||||
}}
|
||||
onClose={() => setBulkOpen(false)}
|
||||
onSubmit={addBulkIngredients}
|
||||
/>
|
||||
|
||||
<BulkPrepStepsModal
|
||||
open={prepOpen}
|
||||
text={prepText}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
type Props = {
|
||||
open: boolean;
|
||||
text: string;
|
||||
error: string;
|
||||
parsing: boolean;
|
||||
onTextChange: (text: string) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export default function BulkIngredientImportModal({
|
||||
open,
|
||||
text,
|
||||
error,
|
||||
parsing,
|
||||
onTextChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
class="bulk-ingredient-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) onClose();
|
||||
}}
|
||||
>
|
||||
<section
|
||||
class="bulk-ingredient-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="bulk-ingredient-title"
|
||||
>
|
||||
<button
|
||||
class="bulk-dialog-close"
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<h2 id="bulk-ingredient-title">Add Ingredients</h2>
|
||||
<p>
|
||||
Type or copy/paste ingredients from a document, spreadsheet, PDF, or
|
||||
website.
|
||||
</p>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={text}
|
||||
onInput={(event) => onTextChange(event.currentTarget.value)}
|
||||
placeholder={
|
||||
"Dry Mix:\n500g flour\n1/2 cup semolina\nsalt to taste\n\nWet:\n5 cloves garlic\n3 egg yolks\nolive oil (room temp)"
|
||||
}
|
||||
/>
|
||||
{error && <p class="bulk-dialog-error">{error}</p>}
|
||||
<div class="bulk-entry-help">
|
||||
<span>
|
||||
Add headers <small>using a colon : eg To Garnish:</small>
|
||||
</span>
|
||||
<span>
|
||||
Add notes to ingredients <small>by putting them in (notes)</small>
|
||||
</span>
|
||||
</div>
|
||||
<footer>
|
||||
<button
|
||||
type="button"
|
||||
class="bulk-cancel"
|
||||
disabled={parsing}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="bulk-submit"
|
||||
disabled={!text.trim() || parsing}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{parsing ? "Parsing…" : "Add Ingredients"}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,14 +19,14 @@ export default function BulkPrepStepsModal({
|
||||
|
||||
return (
|
||||
<div
|
||||
class="bulk-ingredient-backdrop"
|
||||
class="bulk-prep-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) onClose();
|
||||
}}
|
||||
>
|
||||
<section
|
||||
class="bulk-ingredient-dialog bulk-prep-dialog"
|
||||
class="bulk-prep-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="bulk-prep-title"
|
||||
|
||||
@@ -3,5 +3,4 @@ export * from "./utils";
|
||||
export * from "./useRecipeStructure";
|
||||
export { default as RecipeItemRow } from "./RecipeItemRow";
|
||||
export { default as RecipeMethodEditor } from "./RecipeMethodEditor";
|
||||
export { default as BulkIngredientImportModal } from "./BulkIngredientImportModal";
|
||||
export { default as BulkPrepStepsModal } from "./BulkPrepStepsModal";
|
||||
@@ -11,20 +11,11 @@ import type {
|
||||
import { newRecipeId } from "../../lib/recipe-id";
|
||||
import { normal, uid } from "./utils";
|
||||
|
||||
const unitAliases: Record<string, string> = {
|
||||
g: "gram", gram: "gram", grams: "gram", kg: "kilogram", kilogram: "kilogram", kilograms: "kilogram",
|
||||
oz: "ounce_mass", ounce: "ounce_mass", ounces: "ounce_mass", lb: "pound", lbs: "pound", pound: "pound", pounds: "pound",
|
||||
tsp: "teaspoon_us", teaspoon: "teaspoon_us", teaspoons: "teaspoon_us", tbsp: "tablespoon_us", tablespoon: "tablespoon_us", tablespoons: "tablespoon_us",
|
||||
c: "cup_us", cup: "cup_us", cups: "cup_us", ml: "milliliter", milliliter: "milliliter", milliliters: "milliliter",
|
||||
l: "liter", liter: "liter", liters: "liter", ea: "each", each: "each", clove: "each", cloves: "each",
|
||||
};
|
||||
|
||||
export function useRecipeStructure({
|
||||
recipeId,
|
||||
initial,
|
||||
ingredients,
|
||||
recipes,
|
||||
units,
|
||||
autoYield = false,
|
||||
weightRates = {},
|
||||
}: RecipeEditorProps) {
|
||||
@@ -33,10 +24,6 @@ export function useRecipeStructure({
|
||||
const [rowQuery, setRowQuery] = useState<Record<string, string>>({});
|
||||
const [focusedItem, setFocusedItem] = useState<string>();
|
||||
const [draftNotes, setDraftNotes] = useState<Record<string, string>>({});
|
||||
const [bulkOpen, setBulkOpen] = useState(false);
|
||||
const [bulkText, setBulkText] = useState("");
|
||||
const [bulkError, setBulkError] = useState("");
|
||||
const [bulkParsing, setBulkParsing] = useState(false);
|
||||
const [prepOpen, setPrepOpen] = useState(false);
|
||||
const [prepText, setPrepText] = useState("");
|
||||
const [prepError, setPrepError] = useState("");
|
||||
@@ -265,203 +252,6 @@ export function useRecipeStructure({
|
||||
addLine(componentIndex, `recipe:${id}`);
|
||||
};
|
||||
|
||||
const identityTokens = (value: string) =>
|
||||
normal(value)
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.map((token) =>
|
||||
token.length > 3 && token.endsWith("s") ? token.slice(0, -1) : token
|
||||
);
|
||||
|
||||
const addBulkIngredients = async () => {
|
||||
if (!bulkText.trim()) {
|
||||
setBulkError("Enter at least one ingredient.");
|
||||
return;
|
||||
}
|
||||
setBulkParsing(true);
|
||||
setBulkError("");
|
||||
let parsed: {
|
||||
components: Array<{
|
||||
name: string;
|
||||
items: Array<{
|
||||
source_line: string;
|
||||
quantity: number | null;
|
||||
unit: string | null;
|
||||
ingredient: string;
|
||||
preparation: string | null;
|
||||
note: string | null;
|
||||
optional: boolean;
|
||||
alternatives: string[];
|
||||
}>;
|
||||
}>;
|
||||
warnings: string[];
|
||||
};
|
||||
try {
|
||||
const response = await fetch("/api/app/recipes/parse-ingredients", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ text: bulkText }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(result.error ?? "Unable to parse ingredients.");
|
||||
parsed = result;
|
||||
} catch (error) {
|
||||
setBulkError(
|
||||
error instanceof Error ? error.message : "Unable to parse ingredients."
|
||||
);
|
||||
setBulkParsing(false);
|
||||
return;
|
||||
}
|
||||
const available = [
|
||||
...[...ingredients, ...pendingIngredients].map((item) => ({
|
||||
...item,
|
||||
kind: "ingredient" as const,
|
||||
})),
|
||||
...recipes
|
||||
.filter((item) => item.id !== recipeId)
|
||||
.map((item) => ({ ...item, kind: "recipe" as const })),
|
||||
];
|
||||
const components: RecipeStructure["components"] = [];
|
||||
const unmatched: string[] = [];
|
||||
const created: Option[] = [];
|
||||
const takenIds = new Set(
|
||||
[...ingredients, ...pendingIngredients].map((item) => item.id)
|
||||
);
|
||||
const newIngredient = (name: string) => {
|
||||
const base = normal(name).replace(/ /g, "_") || "imported_ingredient";
|
||||
let id = base,
|
||||
suffix = 2;
|
||||
while (takenIds.has(id)) id = `${base}_${suffix++}`;
|
||||
takenIds.add(id);
|
||||
const ingredient = { id, name: name.trim() };
|
||||
created.push(ingredient);
|
||||
const option = { ...ingredient, kind: "ingredient" as const };
|
||||
available.push(option);
|
||||
return option;
|
||||
};
|
||||
for (const parsedComponent of parsed.components) {
|
||||
const component = {
|
||||
id: uid("component"),
|
||||
name: parsedComponent.name || "Main",
|
||||
items: [],
|
||||
notes: [],
|
||||
} as RecipeStructure["components"][number];
|
||||
components.push(component);
|
||||
for (const parsedItem of parsedComponent.items) {
|
||||
const wanted = normal(parsedItem.ingredient);
|
||||
const tokens = identityTokens(wanted);
|
||||
const reducedTokens = tokens.filter(
|
||||
(token) =>
|
||||
![
|
||||
"fresh",
|
||||
"dried",
|
||||
"flake",
|
||||
"leave",
|
||||
"chopped",
|
||||
"minced",
|
||||
"sliced",
|
||||
"crushed",
|
||||
"granulated",
|
||||
].includes(token)
|
||||
);
|
||||
const labels = (item: (typeof available)[number]) =>
|
||||
[item.name, ...(item.aliases ?? [])].map(normal);
|
||||
const labelTokens = (item: (typeof available)[number]) =>
|
||||
labels(item).map(identityTokens);
|
||||
const match =
|
||||
available.find((item) => labels(item).includes(wanted)) ??
|
||||
available.find((item) =>
|
||||
labels(item).some(
|
||||
(label) => label.includes(wanted) || wanted.includes(label)
|
||||
)
|
||||
) ??
|
||||
available.find((item) =>
|
||||
labelTokens(item).some((label) =>
|
||||
tokens.every((token) => label.includes(token))
|
||||
)
|
||||
) ??
|
||||
available.find(
|
||||
(item) =>
|
||||
reducedTokens.length &&
|
||||
labelTokens(item).some((label) =>
|
||||
reducedTokens.every((token) => label.includes(token))
|
||||
)
|
||||
) ??
|
||||
newIngredient(parsedItem.ingredient);
|
||||
const unitId = parsedItem.unit
|
||||
? unitAliases[normal(parsedItem.unit)]
|
||||
: "each";
|
||||
if (!match || !unitId || !units.some((unit) => unit.id === unitId)) {
|
||||
unmatched.push(parsedItem.source_line);
|
||||
continue;
|
||||
}
|
||||
const notes = [
|
||||
parsedItem.preparation,
|
||||
parsedItem.note,
|
||||
parsedItem.alternatives.length
|
||||
? `Alternatives: ${parsedItem.alternatives.join("; ")}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
component.items.push({
|
||||
id: uid("line"),
|
||||
...(match.kind === "ingredient"
|
||||
? { ingredient_id: match.id }
|
||||
: { subrecipe_id: match.id }),
|
||||
quantity: parsedItem.quantity ?? 1,
|
||||
unit_id: unitId,
|
||||
basis_member: false,
|
||||
optional: parsedItem.optional,
|
||||
nutrition_retention_factor: 1,
|
||||
prep: [],
|
||||
...(notes ? { notes } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
const populated = components.filter((entry) => entry.items.length > 0);
|
||||
if (unmatched.length || !populated.length) {
|
||||
setBulkError(
|
||||
unmatched.length
|
||||
? `Could not match: ${unmatched.join("; ")}`
|
||||
: "No ingredients could be matched."
|
||||
);
|
||||
setBulkParsing(false);
|
||||
return;
|
||||
}
|
||||
setPendingIngredients((current) => [...current, ...created]);
|
||||
setData((current) => {
|
||||
const additions = [...populated];
|
||||
const currentComponents = current.components.map((component) => ({
|
||||
...component,
|
||||
items: [...component.items],
|
||||
}));
|
||||
if (
|
||||
additions[0]?.name.toLowerCase() === "main" &&
|
||||
currentComponents.length
|
||||
)
|
||||
currentComponents.at(-1)!.items.push(...additions.shift()!.items);
|
||||
return { ...current, components: [...currentComponents, ...additions] };
|
||||
});
|
||||
const creationMessage = created.length
|
||||
? `${created.length} new canonical ingredient${
|
||||
created.length === 1 ? " is" : "s are"
|
||||
} pending (${created.map((item) => item.name).join(", ")}) and will be created when you save. `
|
||||
: "";
|
||||
setMessage(
|
||||
`${creationMessage}${
|
||||
parsed.warnings.length
|
||||
? `Parser warnings: ${parsed.warnings.join(" ")}`
|
||||
: "Review imported ingredients before saving."
|
||||
}`
|
||||
);
|
||||
setBulkText("");
|
||||
setBulkError("");
|
||||
setBulkOpen(false);
|
||||
setBulkParsing(false);
|
||||
};
|
||||
|
||||
const addBulkPrepSteps = () => {
|
||||
const lines = prepText
|
||||
.split(/\r?\n/)
|
||||
@@ -620,11 +410,10 @@ export function useRecipeStructure({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bulkOpen && !prepOpen) return;
|
||||
if (!prepOpen) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setBulkOpen(false);
|
||||
setPrepOpen(false);
|
||||
}
|
||||
};
|
||||
@@ -634,7 +423,7 @@ export function useRecipeStructure({
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [bulkOpen, prepOpen]);
|
||||
}, [prepOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
document.dispatchEvent(
|
||||
@@ -670,13 +459,6 @@ export function useRecipeStructure({
|
||||
setFocusedItem,
|
||||
draftNotes,
|
||||
setDraftNotes,
|
||||
bulkOpen,
|
||||
setBulkOpen,
|
||||
bulkText,
|
||||
setBulkText,
|
||||
bulkError,
|
||||
setBulkError,
|
||||
bulkParsing,
|
||||
prepOpen,
|
||||
setPrepOpen,
|
||||
prepText,
|
||||
@@ -705,7 +487,6 @@ export function useRecipeStructure({
|
||||
createPendingRowIngredient,
|
||||
createPendingIngredient,
|
||||
createPendingRecipe,
|
||||
addBulkIngredients,
|
||||
addBulkPrepSteps,
|
||||
save,
|
||||
unconvertedYieldItems,
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ export function saveRecipeStructure(database: DatabaseSync, id: string, structur
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const ingredientInsert=database.prepare("INSERT INTO ingredients(id,schema_version,name,status,categories_json,tags_json,source_json) VALUES (?,2,?,'active','[]','[]',?)");
|
||||
for(const [ingredientId,name] of newIngredients)ingredientInsert.run(ingredientId,name,JSON.stringify({source_type:"ai_import",title:"Recipe ingredient import",reviewed:false}));
|
||||
for(const [ingredientId,name] of newIngredients)ingredientInsert.run(ingredientId,name,JSON.stringify({source_type:"manual",title:"Recipe application",reviewed:true}));
|
||||
const recipeInsert=database.prepare("INSERT INTO recipes(id,schema_version,save_version,title,summary,categories_json,tags_json,yield_quantity,yield_unit_id,yield_servings,yield_basis,notes_json,source_json) VALUES (?,2,1,?,NULL,'[]','[]',1,'gram',NULL,'theoretical','[]','{}')");
|
||||
const newRecipeComponentInsert=database.prepare("INSERT INTO recipe_components(recipe_id,id,position,name,notes_json) VALUES (?,'main',1,'Main','[]')");
|
||||
const newRecipeStepInsert=database.prepare("INSERT INTO recipe_steps(recipe_id,id,position,instruction,critical_control_point) VALUES (?,'step_1',1,'Placeholder.',0)");
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeIngredientText,
|
||||
parseIngredientsDeterministic,
|
||||
validateIngredientParse,
|
||||
} from "./ingredient-parser";
|
||||
|
||||
describe("ingredient parser", () => {
|
||||
it("normalizes copied checklist text and Unicode fractions", () => {
|
||||
expect(normalizeIngredientText("▢1 pound beef\n☐ ½ cup water\n▢salt, , to taste"))
|
||||
.toBe("1 pound beef\n1/2 cup water\nsalt, to taste");
|
||||
});
|
||||
|
||||
it("validates a structured parser response", () => {
|
||||
expect(validateIngredientParse({
|
||||
components: [{ name:"Main", items:[{
|
||||
source_line:"1 pound beef", quantity:1, unit:"pound", ingredient:"ground beef",
|
||||
preparation:null, note:null, optional:false, alternatives:[],
|
||||
}] }], warnings:[],
|
||||
}, "1 pound beef")).toMatchObject({ normalized_text:"1 pound beef", components:[{name:"Main"}] });
|
||||
});
|
||||
|
||||
it("rejects invented or malformed quantities", () => {
|
||||
expect(() => validateIngredientParse({
|
||||
components: [{ name:"Main", items:[{
|
||||
source_line:"salt", quantity:-1, unit:null, ingredient:"salt",
|
||||
preparation:null, note:null, optional:false, alternatives:[],
|
||||
}] }], warnings:[],
|
||||
}, "salt")).toThrow("invalid quantity");
|
||||
});
|
||||
|
||||
it("deterministically parses ingredient lines with headers, quantities, units, and notes", () => {
|
||||
const raw = `
|
||||
Dry Mix:
|
||||
500g flour
|
||||
1/2 cup semolina
|
||||
salt to taste
|
||||
|
||||
Wet:
|
||||
5 cloves garlic
|
||||
3 egg yolks
|
||||
olive oil (room temp)
|
||||
`;
|
||||
|
||||
const result = parseIngredientsDeterministic(raw);
|
||||
expect(result.components).toHaveLength(2);
|
||||
expect(result.components[0].name).toBe("Dry Mix");
|
||||
expect(result.components[0].items).toHaveLength(3);
|
||||
expect(result.components[0].items[0]).toMatchObject({
|
||||
quantity: 500,
|
||||
unit: "gram",
|
||||
ingredient: "flour",
|
||||
});
|
||||
expect(result.components[0].items[1]).toMatchObject({
|
||||
quantity: 0.5,
|
||||
unit: "cup",
|
||||
ingredient: "semolina",
|
||||
});
|
||||
expect(result.components[0].items[2]).toMatchObject({
|
||||
ingredient: "salt",
|
||||
note: "to taste",
|
||||
});
|
||||
|
||||
expect(result.components[1].name).toBe("Wet");
|
||||
expect(result.components[1].items).toHaveLength(3);
|
||||
expect(result.components[1].items[0]).toMatchObject({
|
||||
quantity: 5,
|
||||
unit: "clove",
|
||||
ingredient: "garlic",
|
||||
});
|
||||
expect(result.components[1].items[1]).toMatchObject({
|
||||
quantity: 3,
|
||||
ingredient: "egg yolks",
|
||||
});
|
||||
expect(result.components[1].items[2]).toMatchObject({
|
||||
ingredient: "olive oil",
|
||||
note: "room temp",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,270 +0,0 @@
|
||||
export type ParsedIngredient = {
|
||||
source_line: string;
|
||||
quantity: number | null;
|
||||
unit: string | null;
|
||||
ingredient: string;
|
||||
preparation: string | null;
|
||||
note: string | null;
|
||||
optional: boolean;
|
||||
alternatives: string[];
|
||||
};
|
||||
|
||||
export type ParsedIngredientComponent = {
|
||||
name: string;
|
||||
items: ParsedIngredient[];
|
||||
};
|
||||
|
||||
export type IngredientParseResult = {
|
||||
normalized_text: string;
|
||||
components: ParsedIngredientComponent[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
const FRACTIONS: Record<string, string> = {
|
||||
"¼": "1/4", "½": "1/2", "¾": "3/4", "⅐": "1/7", "⅑": "1/9",
|
||||
"⅒": "1/10", "⅓": "1/3", "⅔": "2/3", "⅕": "1/5", "⅖": "2/5",
|
||||
"⅗": "3/5", "⅘": "4/5", "⅙": "1/6", "⅚": "5/6", "⅛": "1/8",
|
||||
"⅜": "3/8", "⅝": "5/8", "⅞": "7/8",
|
||||
};
|
||||
|
||||
export function normalizeIngredientText(value: string): string {
|
||||
return value
|
||||
.replace(/[¼½¾⅐⅑⅒⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞]/g, (match) => FRACTIONS[match] ?? match)
|
||||
.normalize("NFKC")
|
||||
.replace(/⁄/g, "/")
|
||||
.replace(/[\u200B-\u200D\u2060\uFEFF]/g, "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line
|
||||
.replace(/^\s*(?:[▢□☐☑✓✔●•▪◦]|\[(?: |x|X)?\])\s*/, "")
|
||||
.replace(/\s*,\s*,+/g, ",")
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.replace(/\s+,/g, ",")
|
||||
.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function parseFractionValue(raw: string): number | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^\d+(?:\.\d+)?\s*-\s*\d+(?:\.\d+)?$/.test(trimmed)) {
|
||||
const [low, high] = trimmed.split("-").map((v) => Number(v.trim()));
|
||||
return (low + high) / 2;
|
||||
}
|
||||
const mixed = trimmed.match(/^(\d+)\s+([0-9]+)\/([0-9]+)$/);
|
||||
if (mixed) {
|
||||
const whole = Number(mixed[1]);
|
||||
const num = Number(mixed[2]);
|
||||
const den = Number(mixed[3]);
|
||||
if (den === 0) return null;
|
||||
return whole + num / den;
|
||||
}
|
||||
const frac = trimmed.match(/^([0-9]+)\/([0-9]+)$/);
|
||||
if (frac) {
|
||||
const num = Number(frac[1]);
|
||||
const den = Number(frac[2]);
|
||||
if (den === 0) return null;
|
||||
return num / den;
|
||||
}
|
||||
const num = Number(trimmed);
|
||||
return Number.isFinite(num) && num > 0 ? num : null;
|
||||
}
|
||||
|
||||
const UNIT_MAP: Record<string, string> = {
|
||||
g: "gram", gram: "gram", grams: "gram",
|
||||
kg: "kilogram", kilogram: "kilogram", kilograms: "kilogram",
|
||||
oz: "ounce", ounce: "ounce", ounces: "ounce",
|
||||
lb: "pound", lbs: "pound", pound: "pound", pounds: "pound",
|
||||
tsp: "teaspoon", tsps: "teaspoon", teaspoon: "teaspoon", teaspoons: "teaspoon",
|
||||
tbsp: "tablespoon", tbsps: "tablespoon", tbs: "tablespoon", tablespoon: "tablespoon", tablespoons: "tablespoon",
|
||||
c: "cup", cup: "cup", cups: "cup",
|
||||
ml: "milliliter", milliliter: "milliliter", milliliters: "milliliter",
|
||||
l: "liter", liter: "liter", liters: "liter",
|
||||
clove: "clove", cloves: "clove",
|
||||
ea: "each", each: "each",
|
||||
pinch: "pinch", pinches: "pinch",
|
||||
dash: "dash", dashes: "dash",
|
||||
slice: "slice", slices: "slice",
|
||||
sprig: "sprig", sprigs: "sprig",
|
||||
stalk: "stalk", stalks: "stalk",
|
||||
head: "head", heads: "head",
|
||||
bunch: "bunch", bunches: "bunch",
|
||||
can: "can", cans: "can",
|
||||
pkg: "package", package: "package", packages: "package",
|
||||
bottle: "bottle", bottles: "bottle",
|
||||
};
|
||||
|
||||
export function parseSingleIngredientLine(sourceLine: string): ParsedIngredient {
|
||||
const line = sourceLine.trim();
|
||||
const optional = /\boptional\b/i.test(line);
|
||||
|
||||
let workingLine = line;
|
||||
let note: string | null = null;
|
||||
const parenMatch = workingLine.match(/\(([^)]+)\)/);
|
||||
if (parenMatch) {
|
||||
const inside = parenMatch[1].trim();
|
||||
if (!/^\s*optional\s*$/i.test(inside)) {
|
||||
note = inside;
|
||||
}
|
||||
workingLine = workingLine.replace(parenMatch[0], " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
let preparation: string | null = null;
|
||||
const commaIdx = workingLine.indexOf(",");
|
||||
if (commaIdx > 0) {
|
||||
const afterComma = workingLine.slice(commaIdx + 1).trim();
|
||||
workingLine = workingLine.slice(0, commaIdx).trim();
|
||||
if (afterComma) {
|
||||
if (/to taste/i.test(afterComma)) {
|
||||
note = note ? `${note}; ${afterComma}` : afterComma;
|
||||
} else {
|
||||
preparation = afterComma;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let quantity: number | null = null;
|
||||
let unit: string | null = null;
|
||||
let ingredientName = workingLine;
|
||||
|
||||
const qtyUnitPattern = /^(\d+(?:\.\d+)?(?:\s+\d+\/\d+|\/\d+)?|\d+\/\d+|\d+(?:\.\d+)?\s*-\s*\d+(?:\.\d+)?)\s*([a-zA-Z]+)?\s+(.*)$/;
|
||||
const match1 = workingLine.match(qtyUnitPattern);
|
||||
|
||||
if (match1) {
|
||||
const qtyVal = parseFractionValue(match1[1]);
|
||||
const possibleUnit = (match1[2] || "").toLowerCase();
|
||||
if (qtyVal != null) {
|
||||
quantity = qtyVal;
|
||||
if (possibleUnit && UNIT_MAP[possibleUnit]) {
|
||||
unit = UNIT_MAP[possibleUnit];
|
||||
ingredientName = match1[3].trim();
|
||||
} else if (possibleUnit) {
|
||||
ingredientName = `${possibleUnit} ${match1[3]}`.trim();
|
||||
} else {
|
||||
ingredientName = match1[3].trim();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const attachedMatch = workingLine.match(/^(\d+(?:\.\d+)?)\s*([a-zA-Z]+)\s*(.*)$/);
|
||||
if (attachedMatch) {
|
||||
const qtyVal = parseFractionValue(attachedMatch[1]);
|
||||
const possibleUnit = attachedMatch[2].toLowerCase();
|
||||
if (qtyVal != null && UNIT_MAP[possibleUnit]) {
|
||||
quantity = qtyVal;
|
||||
unit = UNIT_MAP[possibleUnit];
|
||||
ingredientName = attachedMatch[3].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (/to taste/i.test(ingredientName)) {
|
||||
ingredientName = ingredientName.replace(/\bto taste\b/i, "").trim();
|
||||
if (!note) note = "to taste";
|
||||
}
|
||||
|
||||
ingredientName = ingredientName.replace(/^of\s+/i, "").trim();
|
||||
if (!ingredientName) ingredientName = line;
|
||||
|
||||
return {
|
||||
source_line: line,
|
||||
quantity,
|
||||
unit,
|
||||
ingredient: ingredientName,
|
||||
preparation,
|
||||
note,
|
||||
optional,
|
||||
alternatives: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIngredientsDeterministic(text: string): IngredientParseResult {
|
||||
const normalizedText = normalizeIngredientText(text);
|
||||
if (!normalizedText) {
|
||||
throw new Error("Enter at least one ingredient.");
|
||||
}
|
||||
|
||||
const lines = normalizedText.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||
const components: ParsedIngredientComponent[] = [];
|
||||
let currentComponent: ParsedIngredientComponent = {
|
||||
name: "Main",
|
||||
items: [],
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const isHeader = line.endsWith(":") || /^#+\s+/.test(line);
|
||||
if (isHeader) {
|
||||
const headerName = line.replace(/^#+\s*/, "").replace(/:$/, "").trim();
|
||||
if (currentComponent.items.length > 0 || currentComponent.name !== "Main") {
|
||||
if (currentComponent.items.length > 0) {
|
||||
components.push(currentComponent);
|
||||
}
|
||||
currentComponent = {
|
||||
name: headerName || "Main",
|
||||
items: [],
|
||||
};
|
||||
} else {
|
||||
currentComponent.name = headerName || "Main";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = parseSingleIngredientLine(line);
|
||||
currentComponent.items.push(item);
|
||||
}
|
||||
|
||||
if (currentComponent.items.length > 0) {
|
||||
components.push(currentComponent);
|
||||
}
|
||||
|
||||
if (components.length === 0) {
|
||||
throw new Error("The parser did not find any ingredients.");
|
||||
}
|
||||
|
||||
return {
|
||||
normalized_text: normalizedText,
|
||||
components,
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseIngredientsWithOllama(text: string): Promise<IngredientParseResult> {
|
||||
return parseIngredientsDeterministic(text);
|
||||
}
|
||||
|
||||
export function validateIngredientParse(value: unknown, normalizedText: string): IngredientParseResult {
|
||||
if (!value || typeof value !== "object") throw new Error("The parser returned an invalid document.");
|
||||
const source = value as Record<string, unknown>;
|
||||
if (!Array.isArray(source.components) || !Array.isArray(source.warnings)) {
|
||||
throw new Error("The parser response is missing components or warnings.");
|
||||
}
|
||||
const components = source.components.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") throw new Error("The parser returned an invalid component.");
|
||||
const component = entry as Record<string, unknown>;
|
||||
if (typeof component.name !== "string" || !component.name.trim() || !Array.isArray(component.items)) {
|
||||
throw new Error("The parser returned an invalid component.");
|
||||
}
|
||||
const items = component.items.map((rawItem) => {
|
||||
if (!rawItem || typeof rawItem !== "object") throw new Error("The parser returned an invalid ingredient.");
|
||||
const item = rawItem as Record<string, unknown>;
|
||||
if (typeof item.source_line !== "string") throw new Error("The parser returned an ingredient without its source line.");
|
||||
if (typeof item.ingredient !== "string" || !item.ingredient.trim()) throw new Error(`The parser returned an unnamed ingredient for: ${item.source_line}`);
|
||||
if (!(item.quantity === null || (typeof item.quantity === "number" && Number.isFinite(item.quantity) && item.quantity > 0))) {
|
||||
throw new Error(`The parser returned an invalid quantity for: ${item.source_line}`);
|
||||
}
|
||||
return {
|
||||
source_line: String(item.source_line),
|
||||
quantity: item.quantity as number | null,
|
||||
unit: item.unit ? String(item.unit) : null,
|
||||
ingredient: String(item.ingredient),
|
||||
preparation: item.preparation ? String(item.preparation) : null,
|
||||
note: item.note ? String(item.note) : null,
|
||||
optional: Boolean(item.optional),
|
||||
alternatives: Array.isArray(item.alternatives) ? (item.alternatives as string[]) : [],
|
||||
} satisfies ParsedIngredient;
|
||||
});
|
||||
return { name: component.name.trim(), items };
|
||||
}).filter((comp) => comp.items.length > 0);
|
||||
|
||||
if (!components.length) throw new Error("The parser did not find any ingredients.");
|
||||
return { normalized_text: normalizedText, components, warnings: source.warnings as string[] };
|
||||
}
|
||||
@@ -697,7 +697,6 @@
|
||||
color: #202962;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.application-body .bulk-add-trigger,
|
||||
.application-body .bulk-prep-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -717,7 +716,6 @@
|
||||
text-decoration: none;
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
.application-body .bulk-add-trigger:hover,
|
||||
.application-body .bulk-prep-btn:hover {
|
||||
color: #3d5df6;
|
||||
border-color: #3d5df6;
|
||||
@@ -741,7 +739,7 @@
|
||||
font:italic 14px var(--sans);
|
||||
}
|
||||
.application-body .unified-recipe-editor .add-line-notes::placeholder { color:#a5a9c1; }
|
||||
.bulk-ingredient-backdrop {
|
||||
.bulk-prep-backdrop {
|
||||
position:fixed;
|
||||
z-index:1000;
|
||||
inset:0;
|
||||
@@ -750,10 +748,10 @@
|
||||
padding:24px;
|
||||
background:rgba(5,8,31,.52);
|
||||
}
|
||||
.bulk-ingredient-dialog {
|
||||
.bulk-prep-dialog {
|
||||
position:relative;
|
||||
display:flex;
|
||||
width:min(780px,100%);
|
||||
width:min(860px,100%);
|
||||
max-height:calc(100vh - 48px);
|
||||
padding:52px 68px 48px;
|
||||
overflow:auto;
|
||||
@@ -763,11 +761,11 @@
|
||||
border-radius:10px;
|
||||
box-shadow:0 24px 70px rgba(5,8,31,.28);
|
||||
}
|
||||
.bulk-ingredient-dialog h2 { margin:0 0 24px; font-size:30px; font-weight:700; }
|
||||
.bulk-ingredient-dialog > p:not(.bulk-dialog-error) { margin:0 0 8px; color:#8b8d99; font-size:15px; font-weight:500; }
|
||||
.bulk-ingredient-dialog textarea {
|
||||
.bulk-prep-dialog h2 { margin:0 0 24px; font-size:30px; font-weight:700; }
|
||||
.bulk-prep-dialog > p:not(.bulk-dialog-error) { margin:0 0 8px; color:#8b8d99; font-size:15px; font-weight:500; }
|
||||
.bulk-prep-dialog textarea {
|
||||
width:100%;
|
||||
min-height:330px;
|
||||
min-height:390px;
|
||||
padding:18px 14px;
|
||||
resize:vertical;
|
||||
color:#050841;
|
||||
@@ -777,7 +775,7 @@
|
||||
border-radius:0;
|
||||
font:15px/1.55 var(--sans);
|
||||
}
|
||||
.bulk-ingredient-dialog textarea::placeholder { color:#a5a9c1; opacity:1; }
|
||||
.bulk-prep-dialog textarea::placeholder { color:#a5a9c1; opacity:1; }
|
||||
.bulk-dialog-close {
|
||||
position:absolute;
|
||||
top:28px;
|
||||
@@ -796,13 +794,11 @@
|
||||
.bulk-dialog-error { margin:7px 0 0; color:#ef3d4d; font-size:13px; font-weight:600; }
|
||||
.bulk-entry-help { display:flex; margin:18px 0 34px; flex-direction:column; gap:12px; font-size:14px; }
|
||||
.bulk-entry-help small { color:#a5a9c1; font-size:inherit; }
|
||||
.bulk-ingredient-dialog footer { display:flex; align-items:center; justify-content:flex-end; gap:44px; }
|
||||
.bulk-ingredient-dialog footer button { min-height:48px; padding:10px 24px; cursor:pointer; border:0; font-weight:700; }
|
||||
.bulk-ingredient-dialog .bulk-cancel { color:#3d5df6; background:transparent; }
|
||||
.bulk-ingredient-dialog .bulk-submit { min-width:250px; color:#fff; background:#647df8; border-radius:28px; }
|
||||
.bulk-ingredient-dialog .bulk-submit:disabled { background:#aeb9f8; cursor:not-allowed; }
|
||||
.bulk-prep-dialog { width:min(860px,100%); }
|
||||
.bulk-prep-dialog textarea { min-height:390px; }
|
||||
.bulk-prep-dialog footer { display:flex; align-items:center; justify-content:flex-end; gap:44px; }
|
||||
.bulk-prep-dialog footer button { min-height:48px; padding:10px 24px; cursor:pointer; border:0; font-weight:700; }
|
||||
.bulk-prep-dialog .bulk-cancel { color:#3d5df6; background:transparent; }
|
||||
.bulk-prep-dialog .bulk-submit { min-width:250px; color:#fff; background:#647df8; border-radius:28px; }
|
||||
.bulk-prep-dialog .bulk-submit:disabled { background:#aeb9f8; cursor:not-allowed; }
|
||||
.application-body .unified-recipe-editor .method-editor li.prep-heading { min-height:70px; padding-block:18px; background:#ffffff; }
|
||||
.application-body .unified-recipe-editor .method-editor li.prep-heading textarea { min-height:34px; font-weight:700; }
|
||||
.application-body .unified-recipe-editor .method-editor li.prep-note { min-height:76px; background:#ffffff; }
|
||||
|
||||
@@ -621,13 +621,13 @@
|
||||
padding-block: 8px !important;
|
||||
}
|
||||
|
||||
.bulk-ingredient-backdrop { padding: 0 !important; }
|
||||
.bulk-ingredient-dialog { width: 100% !important; max-height: 100vh !important; min-height: 100vh !important; padding: 52px 22px 28px !important; border-radius: 0 !important; }
|
||||
.bulk-ingredient-dialog h2 { font-size: 24px !important; }
|
||||
.bulk-ingredient-dialog textarea { min-height: 260px !important; }
|
||||
.bulk-prep-backdrop { padding: 0 !important; }
|
||||
.bulk-prep-dialog { width: 100% !important; max-height: 100vh !important; min-height: 100vh !important; padding: 52px 22px 28px !important; border-radius: 0 !important; }
|
||||
.bulk-prep-dialog h2 { font-size: 24px !important; }
|
||||
.bulk-prep-dialog textarea { min-height: 260px !important; }
|
||||
.bulk-dialog-close { top: 16px !important; right: 18px !important; }
|
||||
.bulk-ingredient-dialog footer { gap: 12px !important; }
|
||||
.bulk-ingredient-dialog .bulk-submit { min-width: 0 !important; flex: 1 !important; }
|
||||
.bulk-prep-dialog footer { gap: 12px !important; }
|
||||
.bulk-prep-dialog .bulk-submit { min-width: 0 !important; flex: 1 !important; }
|
||||
|
||||
|
||||
.application-body .recipe-book-workspace {
|
||||
|
||||
Reference in new issue
Block a user