Reviewed-on: #14 Co-authored-by: Nicholas Ward <nicholaspward@outlook.com>
187 lines
9.3 KiB
TypeScript
187 lines
9.3 KiB
TypeScript
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, (value) => FRACTIONS[value] ?? value)
|
||
.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");
|
||
}
|
||
|
||
const responseSchema = {
|
||
type: "object",
|
||
additionalProperties: false,
|
||
required: ["components", "warnings"],
|
||
properties: {
|
||
components: {
|
||
type: "array",
|
||
items: {
|
||
type: "object",
|
||
additionalProperties: false,
|
||
required: ["name", "items"],
|
||
properties: {
|
||
name: { type: "string" },
|
||
items: {
|
||
type: "array",
|
||
items: {
|
||
type: "object",
|
||
additionalProperties: false,
|
||
required: ["source_line", "quantity", "unit", "ingredient", "preparation", "note", "optional", "alternatives"],
|
||
properties: {
|
||
source_line: { type: "string" },
|
||
quantity: { type: ["number", "null"] },
|
||
unit: { type: ["string", "null"] },
|
||
ingredient: { type: "string" },
|
||
preparation: { type: ["string", "null"] },
|
||
note: { type: ["string", "null"] },
|
||
optional: { type: "boolean" },
|
||
alternatives: { type: "array", items: { type: "string" } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
warnings: { type: "array", items: { type: "string" } },
|
||
},
|
||
} as const;
|
||
|
||
const systemPrompt = `You are a purpose-built culinary ingredient parser. Convert normalized recipe ingredient text into JSON only.
|
||
|
||
Rules:
|
||
- Preserve the meaning and never invent an ingredient, amount, unit, or preparation.
|
||
- Convert fractions and mixed numbers to decimal quantities.
|
||
- Use singular conventional unit names such as gram, ounce, pound, teaspoon, tablespoon, cup, milliliter, liter, or each.
|
||
- A line ending in a colon is a component header. Use "Main" when there is no header.
|
||
- Group consecutive lines under one component. Do not create a new Main component for each line.
|
||
- ingredient contains only the ingredient identity, never its quantity, unit, size, preparation, or note. For example, "15 ounces tomato sauce" becomes quantity 15, unit "ounce", ingredient "tomato sauce".
|
||
- Treat sizes such as small, medium, and large as preparation or notes and use unit "each". For example, "1 medium onion, chopped" becomes quantity 1, unit "each", ingredient "onion", preparation "medium; chopped".
|
||
- Split a source line containing two independently required ingredients into two items, retaining the same source_line.
|
||
- Specifically, "salt and freshly ground black pepper, to taste" becomes separate salt and black pepper items.
|
||
- Keep alternatives in alternatives instead of adding them as required items.
|
||
- Put physical treatment such as chopped, minced, sliced, freshly ground, dried, or drained in preparation.
|
||
- Put serving instructions, "to taste", temperatures, and other qualifications in note.
|
||
- Set optional true when the source explicitly says optional.
|
||
- Use null quantity and unit when the source supplies none. Do not guess.
|
||
- Use null rather than an empty string. Do not repeat alternatives in note.
|
||
- Retain every source line. Add a warning for ambiguity.`;
|
||
|
||
function isNullableString(value: unknown): value is string | null {
|
||
return value === null || typeof value === "string";
|
||
}
|
||
|
||
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((entry) => {
|
||
if (!entry || typeof entry !== "object") throw new Error("The parser returned an invalid ingredient.");
|
||
const item = entry 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}`);
|
||
if (!isNullableString(item.unit) || !isNullableString(item.preparation) || !isNullableString(item.note)) throw new Error(`The parser returned invalid text fields for: ${item.source_line}`);
|
||
if (typeof item.optional !== "boolean" || !Array.isArray(item.alternatives) || !item.alternatives.every((value) => typeof value === "string")) throw new Error(`The parser returned invalid qualifications for: ${item.source_line}`);
|
||
const sourceLine = item.source_line.trim();
|
||
const sourceSaysOptional = /\boptional\b/i.test(sourceLine);
|
||
const alternatives = (item.alternatives as string[]).map((value) => value.trim()).filter((value) => value && !/^(?:none|optional)$/i.test(value));
|
||
const note = item.note?.trim() || null;
|
||
return {
|
||
source_line: sourceLine,
|
||
quantity: item.quantity as number | null,
|
||
unit: item.unit?.trim().toLowerCase() || null,
|
||
ingredient: item.ingredient.trim(),
|
||
preparation: item.preparation?.trim() || null,
|
||
note: note && (!/^optional$/i.test(note) || sourceSaysOptional) ? note : null,
|
||
optional: sourceSaysOptional,
|
||
alternatives,
|
||
} satisfies ParsedIngredient;
|
||
});
|
||
return { name: component.name.trim(), items };
|
||
}).filter((component) => component.items.length > 0);
|
||
if (!components.length) throw new Error("The parser did not find any ingredients.");
|
||
if (!source.warnings.every((value) => typeof value === "string")) throw new Error("The parser returned invalid warnings.");
|
||
const consolidated: ParsedIngredientComponent[] = [];
|
||
for (const component of components) {
|
||
const previous = consolidated.at(-1);
|
||
if (previous?.name.toLowerCase() === component.name.toLowerCase()) previous.items.push(...component.items);
|
||
else consolidated.push(component);
|
||
}
|
||
return { normalized_text: normalizedText, components: consolidated, warnings: source.warnings as string[] };
|
||
}
|
||
|
||
export async function parseIngredientsWithOllama(text: string): Promise<IngredientParseResult> {
|
||
const normalizedText = normalizeIngredientText(text);
|
||
if (!normalizedText) throw new Error("Enter at least one ingredient.");
|
||
const endpoint = process.env.FORMULATION_OLLAMA_URL ?? "http://10.0.10.211:11434/api/chat";
|
||
const model = process.env.FORMULATION_INGREDIENT_PARSER_MODEL ?? "qwen3:4b-instruct";
|
||
const response = await fetch(endpoint, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
signal: AbortSignal.timeout(90_000),
|
||
body: JSON.stringify({
|
||
model,
|
||
stream: false,
|
||
think: false,
|
||
format: responseSchema,
|
||
options: { temperature: 0, num_predict: 6000 },
|
||
messages: [
|
||
{ role: "system", content: systemPrompt },
|
||
{ role: "user", content: normalizedText },
|
||
],
|
||
}),
|
||
});
|
||
if (!response.ok) throw new Error(`Ingredient parser service failed (${response.status}).`);
|
||
const payload = await response.json() as { message?: { content?: string } };
|
||
const content = payload.message?.content?.trim();
|
||
if (!content) throw new Error("Ingredient parser returned an empty response.");
|
||
let parsed: unknown;
|
||
const unwrapped = content.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
||
const firstBrace = unwrapped.indexOf("{");
|
||
const lastBrace = unwrapped.lastIndexOf("}");
|
||
const json = firstBrace >= 0 && lastBrace > firstBrace ? unwrapped.slice(firstBrace, lastBrace + 1) : unwrapped;
|
||
try { parsed = JSON.parse(json); }
|
||
catch { throw new Error("Ingredient parser returned malformed JSON."); }
|
||
return validateIngredientParse(parsed, normalizedText);
|
||
}
|