Remove bulk ingredient parsing feature and Ollama leftovers
Build & Deploy Formulation / Build & Push Image (push) Successful in 36s
Build & Deploy Formulation / deploy (push) Successful in 47s

This commit is contained in:
nicholas committed 2026-10-07 08:53:06 -05:00
1 parent 9162992415
commit a8a4eb488f
13 files changed
+26 -746

No files matched your search

-6
View File
@@ -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
+2 -3
View File
@@ -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 });
}
};
-32
View File
@@ -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"
-1
View File
@@ -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
View File
@@ -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)");
-80
View File
@@ -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",
});
});
});
-270
View File
@@ -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[] };
}
+13 -17
View File
@@ -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; }
+6 -6
View File
@@ -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 {