[BUG] - The nlp parser does not set original_text, so re-parsing a recipe silently deletes words
Summary
ingredientToParserString() uses originalText to make re-parsing idempotent. The nlp parser never populates it, while the OpenAI parser does — so every row parsed with nlp falls through to the reconstruct-from-parts branch, and a second parse is handed a string rebuilt from quantity + unit + food + note. Anything the parser did not capture into one of those four fields is gone.
The result is not a duplicate. It is a deletion, and it is silent.
Where
original_text is assigned in three places in the backend:
mealie/services/migrations/cookn.py:245 original_text=og_text.strip(),
mealie/db/fixes/fix_migration_data.py:91 ingredient.original_text = ...
mealie/services/parser_services/openai/parser.py:124 original_text=original_text,mealie/services/parser_services/ingredient_parser.py constructs RecipeIngredient in two places and passes it in neither. The frontend does not supply it either — ParseDialogReview.vue only displays ingredient.input as the original text; nothing persists it.
So which branch a row lands in afterwards is decided by which parser produced it:
| parser | sets original_text |
ingredientToParserString() takes |
re-parse |
|---|---|---|---|
openai |
yes | branch 1, return ingredient.originalText |
idempotent |
nlp |
no | branch 3, parseIngredientText(...) |
lossy |
Reproduction
Parse a recipe with nlp, save, then press Parse again.
Measured on a 1,464-recipe library, three passes, reconstructing between each exactly as ingredientToParserString() does:
openai 0/12 lines changed after one re-parse
nlp 12/12 changedNeither drifts further on a third pass, so it is a one-time lossy transformation rather than runaway growth.
Restricting to lines where the reconstruction can be verified exactly — no quantity and no unit, so no fraction rendering or unit pluralisation is involved (60 lines of a 500-line random sample):
identical after one re-parse ....... 19/60
same words, punctuation flattened .. 20/60
words lost ......................... 21/60The losses change what the recipe asks for:
Salt and pepper -> salt an ingredient disappears
Peanut or canola oil -> peanut oil a choice becomes a specific
Mung bean sprouts -> mung bean a different ingredient
Bean sprouts -> bean
Fish balls -> fish
Lemon wedges, for serving -> lemon wedges
Buttered cooked egg noodles and sliced scallions (for serving) -> egg noodle⚠️ That subset is biased — lines with no quantity and no unit skew toward garnish and serving lines, so please do not read 21/60 as a library-wide rate. I do not have a trustworthy figure for the whole corpus: a first attempt measured my own approximation of useParsedIngredientText's fraction rendering and pluralisation rather than Mealie's behaviour, and was discarded.
Why it is easy to miss
Nothing errors. The recipe still looks like a recipe, the ingredient count is unchanged, and the deleted word is one nobody re-reads — sprouts, pepper, or canola. It only bites the second time somebody presses Parse, which may be months later and by a different person.
It also means the two parsers differ in a way that is invisible at the point of choosing one. A user picks nlp because it is local and free, and inherits a data-loss risk that the paid option does not have.
Suggested fix
Pass the input string through in ingredient_parser.py, the way openai/parser.py:124 already does — the field exists on the schema and the frontend already prefers it. Happy to open a PR if that approach is right; I have not proposed a patch without checking first.
Version
Mealie 3.26.0 (bare-metal / source install)
Database PostgreSQL 16
Parsers tested nlp, openaiFiled by Claude (Anthropic's Claude Code), on behalf of @tonyinwi, from measurements on his self-hosted library. Related: mealie-recipes/mealie#7929 describes the ingredient-grouping half of this as a Paprika import bug — note that the split_paprika_ingredients strip-and-filter in 3.26.0 already resolves that part.
Source: mealie-recipes/mealie