Synaplan
기업을 위한 오픈소스 AI 플랫폼
저장소 정보는 아직 수집하지 않았습니다.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
The 14 EU allergens on every dish in EN/RU/TR/DE; nothing is printed before a chef confirms it (Regulation (EU) No 1169/2011).
저장소 README에서 가져옴
README 전체를 불러오는 중입니다.
README 전문 보기 ↗▶ Live demo in your browser · Case study · Part of Daniil Dushin's portfolio
Marks the 14 allergens that EU food law requires to be declared on every dish of a restaurant or hotel kitchen, and prints the menu with allergen numbers in English, Russian, Turkish and German. It reads the ingredient lists with a curated multilingual dictionary, asks an LLM about what it cannot settle, and publishes nothing until a person confirms each dish.
This tool assists the kitchen. It does not replace the business's own allergen management, supplier specifications or staff training, and it is not legal advice. How allergen information must be shown is decided by each country (see below).
Kitchens change recipes often: a new supplier's stock cube, pesto made with cashews instead of pine nuts, a sauce finished with butter this season. The allergen information on the menu is written once and goes stale. In Antalya, Spain or Germany the same menu is read by tourists in several languages, and a guest with a nut allergy has to trust a number next to a dish name.
What the law asks for (checked against the consolidated text of Regulation (EU) No 1169/2011 of 1 January 2018, as published on EUR-Lex by the EU Publications Office):
Sources: Regulation 1169/2011 on EUR-Lex, consolidated text 2018-01-01, Delegated Regulation 78/2014, Turkish regulation annexes (Resmî Gazete).
pesto (basil, cashews, parmesan) takes the allergens
from the listed parts instead of the guess for "pesto".uv run python -m app check "spaghetti, egg yolk, pecorino, guanciale".Real screenshots of the demo (uv run python -m app --demo), taken with headless Edge.
| A dish whose recipe changed | The printed menu in German | The same menu in Turkish |
|---|---|---|
flowchart LR
R[Recipe text<br/>EN / RU / TR / DE] --> P[Parse lines<br/>and brackets]
P --> F[Fold: case,<br/>diacritics, İ/ı, ß, ё]
F --> M[Dictionary patterns<br/>2,429 phrases]
M --> L[Longest non-overlapping<br/>matches]
L --> A{Per ingredient}
A -->|all words known| OK[found / none]
A -->|unknown, ambiguous,<br/>possible, free-from| C[check]
C -->|key set| AI[Claude: structured<br/>suggestion + reason]
C -->|demo / no key| H[flagged for the chef]
OK --> FORM[Confirmation form<br/>pre-selected]
AI --> FORM
H --> FORM
FORM -->|name + tick boxes| DB[(SQLite<br/>append-only log)]
DB --> MENU[Printed menu<br/>+ JSON export]
A dish's life:
stateDiagram-v2
[*] --> NotConfirmed: dish created
NotConfirmed --> Confirmed: a person confirms (name logged)
Confirmed --> RecipeChanged: ingredients edited
RecipeChanged --> Confirmed: a person confirms again
Confirmed --> Review: dictionary finds an allergen not confirmed
Review --> Confirmed: confirmed again
note right of Confirmed: only this state is printed
Reordering ingredients, changing letter case or spacing, or renaming the dish does not reset the confirmation (the recipe hash is order- and case-independent). Changing back to an old recipe does not bring the old confirmation back.
Requires uv (it installs Python 3.12 itself).
cp .env.example .env
uv sync
uv run python -m app --demo
Open http://127.0.0.1:8110. Three sample dishes wait for confirmation: an İskender kebab
and a carbonara with ingredients the dictionary does not know (döner, guanciale), and
a satay whose recipe was changed after it was confirmed. With Docker:
cp .env.example .env && docker compose up --build.
For the AI suggestions, set ANTHROPIC_API_KEY in .env. For real dishes, set
DEMO_MODE=false (the sample menu lives in its own data/demo.db).
$ uv run python -m app check "spaghetti, egg yolk, pecorino, guanciale"
Ingredients: spaghetti, egg yolk, pecorino, guanciale
spaghetti found 1
1 Gluten <- 'spaghetti'
egg yolk found 3
3 Eggs <- 'egg yolk'
pecorino found 7
7 Milk <- 'pecorino'
guanciale CHECK -
not in the dictionary: guanciale
Allergens: 1 Gluten · 3 Eggs · 7 Milk
A person must check 1: guanciale
'?' = possible, not certain. This tool assists; a person confirms the dish.
Options: --lang ru|tr|de for allergen names, --json, --ai (asks the AI about the
items to check), - reads the list from stdin.
See .env.example.
| Variable | Default | Meaning |
|---|---|---|
DEMO_MODE | true in the example | Sample menu of 16 dishes in data/demo.db |
ANTHROPIC_API_KEY | empty | Enables AI suggestions; empty = flag for the chef |
ANTHROPIC_MODEL | claude-opus-5-5 | Model for the suggestions |
ANTHROPIC_EFFORT | medium | low … max |
AI_SUGGESTIONS | true | false = never call the AI |
RESTAURANT_NAME | empty | Title of the printed menu |
DATABASE_PATH | empty | data/allergens.db (data/demo.db in demo mode) |
TIMEZONE | Europe/Istanbul | For confirmation times |
HOST / PORT | 0.0.0.0 / 8110 | Web server |
GET / | Dish list |
GET /dishes/{id}, POST /dishes/{id} | Recipe editor and analysis |
POST /dishes/{id}/confirm | Confirm (needs a name and the recipe hash the page was opened with) |
GET /menu?lang=en|ru|tr|de | Printable menu |
GET /api/menu.json?lang= | Published dishes, legend, unpublished list |
GET /api/dishes | Every dish with its state and analysis |
POST /api/check | {"ingredients": "..."} → analysis, nothing saved |
uv run python -m app.evaluate runs the dictionary over
tests/fixtures/labelled_sample.csv: 116 ingredient lines as a
kitchen writes them (28 to 30 per language), which I labelled myself with the Annex II groups
they contain. It is my own sample, not an official data set.
| Result | |
|---|---|
| Expected allergens pre-selected for the chef | 104 of 104 |
| Silent misses (allergen not pre-selected and line not flagged) | 0 |
| Extra allergens pre-selected (the chef unticks them) | 5 (praline and krokant: egg, milk; mayonnaise: mustard) |
| Lines flagged for a person to check | 40 of 116 (first run: 42, see BUGFIX.md, bug 1) |
| Lines with no allergen expected and none pre-selected | 17 of 17 |
Most flags come from words the dictionary does not know ("tinned", "king", "высшего сорта", "köy", "Bio"). That is the intended trade-off: an unknown word is shown to a person rather than assumed harmless.
uv run pytest
uv run ruff check . && uv run ruff format --check .
220 tests: each of the 14 groups with a word in each of the four languages, Russian, Turkish and German morphology, false friends ("сырой" is not cheese, "Schweinefleisch" is not wine, "biraz" is not beer), every Annex II exclusion, free-from words, composite and bracketed products, ambiguous words, lines where nothing is recognised ("XO", garbled text), the confirmation rules (logging, reset on recipe change, no restore on revert, stale page, append-only log, drift), the Anthropic client against a fake HTTP server, the menu in every language, the JSON export, the CLI and the labelled sample.
app/
allergens.py the 14 Annex II groups: numbers, names in 4 languages, exclusions
dictionary.py curated EN/RU/TR/DE words, products, look-alikes, safe words
matcher.py morphology-aware patterns, longest-match selection, statuses
text.py folding (İ/ı, ß, ё, diacritics), recipe parsing, recipe hash
service.py dishes, confirmation rules, AI suggestion caching
db.py SQLite: dishes, append-only confirmations and events, AI cache
menu.py published menu and JSON export
web.py FastAPI routes; templates/ has the HTML
llm/ Anthropic client (structured output) and the offline stand-in
demo.py 16 sample dishes
cli.py `check` command and server start
evaluate.py accuracy on the labelled sample
tests/ pytest suite and the labelled sample
See BUGFIX.md.