From 45bc449ea07f43ff8d0198c35936a706e94715cd Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 24 Jul 2026 21:37:03 +0200 Subject: [PATCH] chore: initial import --- .gitignore | 17 + README.md | 4 + deploy/local/boehmitools-main.service | 21 + deploy/local/instance.env.example | 5 + deploy/local/start-main.sh.example | 11 + plugins/README.md | 295 ++++ plugins/tandoor-ai-import/TOOL-README.md | 201 +++ plugins/tandoor-ai-import/TOOL-UPGRADE.md | 43 + plugins/tandoor-ai-import/VERSION | 1 + plugins/tandoor-ai-import/app/__init__.py | 0 plugins/tandoor-ai-import/app/auth.py | 41 + plugins/tandoor-ai-import/app/main.py | 235 +++ plugins/tandoor-ai-import/app/models.py | 197 +++ .../tandoor-ai-import/app/openai_service.py | 124 ++ plugins/tandoor-ai-import/app/quality.py | 118 ++ .../tandoor-ai-import/app/source_extractor.py | 267 +++ plugins/tandoor-ai-import/app/storage.py | 53 + .../tandoor-ai-import/app/tandoor_service.py | 798 +++++++++ plugins/tandoor-ai-import/backend.py | 37 + .../examples/crispy-chickpeas.json | 42 + plugins/tandoor-ai-import/plugin.json | 19 + plugins/tandoor-ai-import/static/index.html | 614 +++++++ .../tests/integration_mock.py | 85 + plugins/tandoor-ai-import/tests/selftest.py | 215 +++ plugins/tandoor-categories/TOOL-README.md | 37 + plugins/tandoor-categories/backend.py | 194 +++ plugins/tandoor-categories/plugin.json | 20 + plugins/tandoor-categories/static/index.html | 330 ++++ plugins/tandoor-categories/tool/categories.py | 402 +++++ plugins/tandoor-cleanup/TOOL-README.md | 141 ++ plugins/tandoor-cleanup/backend.py | 275 +++ plugins/tandoor-cleanup/plugin.json | 19 + plugins/tandoor-cleanup/static/index.html | 624 +++++++ plugins/tandoor-cleanup/tool/cleanup.py | 1377 +++++++++++++++ plugins/tandoor-conversions/TOOL-README.md | 53 + plugins/tandoor-conversions/backend.py | 194 +++ plugins/tandoor-conversions/plugin.json | 18 + plugins/tandoor-conversions/static/index.html | 343 ++++ .../tandoor-conversions/tool/conversions.py | 501 ++++++ plugins/tandoor-lint/TOOL-README.md | 42 + plugins/tandoor-lint/backend.py | 104 ++ plugins/tandoor-lint/plugin.json | 19 + plugins/tandoor-lint/static/index.html | 200 +++ plugins/tandoor-lint/tool/lint.py | 331 ++++ plugins/tandoor-nutrition/TOOL-README.md | 70 + plugins/tandoor-nutrition/backend.py | 245 +++ plugins/tandoor-nutrition/plugin.json | 19 + plugins/tandoor-nutrition/static/index.html | 565 +++++++ plugins/tandoor-nutrition/tool/nutrition.py | 1142 +++++++++++++ plugins/werkzeuge-wandler/TOOL-README.md | 82 + plugins/werkzeuge-wandler/backend.py | 31 + plugins/werkzeuge-wandler/plugin.json | 18 + plugins/werkzeuge-wandler/static/index.html | 1490 +++++++++++++++++ 53 files changed, 12329 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 deploy/local/boehmitools-main.service create mode 100644 deploy/local/instance.env.example create mode 100755 deploy/local/start-main.sh.example create mode 100644 plugins/README.md create mode 100644 plugins/tandoor-ai-import/TOOL-README.md create mode 100644 plugins/tandoor-ai-import/TOOL-UPGRADE.md create mode 100644 plugins/tandoor-ai-import/VERSION create mode 100644 plugins/tandoor-ai-import/app/__init__.py create mode 100644 plugins/tandoor-ai-import/app/auth.py create mode 100644 plugins/tandoor-ai-import/app/main.py create mode 100644 plugins/tandoor-ai-import/app/models.py create mode 100644 plugins/tandoor-ai-import/app/openai_service.py create mode 100644 plugins/tandoor-ai-import/app/quality.py create mode 100644 plugins/tandoor-ai-import/app/source_extractor.py create mode 100644 plugins/tandoor-ai-import/app/storage.py create mode 100644 plugins/tandoor-ai-import/app/tandoor_service.py create mode 100644 plugins/tandoor-ai-import/backend.py create mode 100644 plugins/tandoor-ai-import/examples/crispy-chickpeas.json create mode 100644 plugins/tandoor-ai-import/plugin.json create mode 100644 plugins/tandoor-ai-import/static/index.html create mode 100644 plugins/tandoor-ai-import/tests/integration_mock.py create mode 100644 plugins/tandoor-ai-import/tests/selftest.py create mode 100644 plugins/tandoor-categories/TOOL-README.md create mode 100644 plugins/tandoor-categories/backend.py create mode 100644 plugins/tandoor-categories/plugin.json create mode 100644 plugins/tandoor-categories/static/index.html create mode 100644 plugins/tandoor-categories/tool/categories.py create mode 100644 plugins/tandoor-cleanup/TOOL-README.md create mode 100644 plugins/tandoor-cleanup/backend.py create mode 100644 plugins/tandoor-cleanup/plugin.json create mode 100644 plugins/tandoor-cleanup/static/index.html create mode 100644 plugins/tandoor-cleanup/tool/cleanup.py create mode 100644 plugins/tandoor-conversions/TOOL-README.md create mode 100644 plugins/tandoor-conversions/backend.py create mode 100644 plugins/tandoor-conversions/plugin.json create mode 100644 plugins/tandoor-conversions/static/index.html create mode 100644 plugins/tandoor-conversions/tool/conversions.py create mode 100644 plugins/tandoor-lint/TOOL-README.md create mode 100644 plugins/tandoor-lint/backend.py create mode 100644 plugins/tandoor-lint/plugin.json create mode 100644 plugins/tandoor-lint/static/index.html create mode 100644 plugins/tandoor-lint/tool/lint.py create mode 100644 plugins/tandoor-nutrition/TOOL-README.md create mode 100644 plugins/tandoor-nutrition/backend.py create mode 100644 plugins/tandoor-nutrition/plugin.json create mode 100644 plugins/tandoor-nutrition/static/index.html create mode 100644 plugins/tandoor-nutrition/tool/nutrition.py create mode 100644 plugins/werkzeuge-wandler/TOOL-README.md create mode 100644 plugins/werkzeuge-wandler/backend.py create mode 100644 plugins/werkzeuge-wandler/plugin.json create mode 100644 plugins/werkzeuge-wandler/static/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d565b2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ +venv/ +.env +.env.* +!.env.example +data/ +logs/ +backups/ +*.pem +*.key +secrets/ +.idea/ +.vscode/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d197ee --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# boehmitools-main + +Alle fachlichen boehmitools-Plugins außer Trainingsplan und Trainingstracker. +Das gemeinsame Grundgerüst kommt ausschließlich aus boehmitools-core. diff --git a/deploy/local/boehmitools-main.service b/deploy/local/boehmitools-main.service new file mode 100644 index 0000000..3cb7266 --- /dev/null +++ b/deploy/local/boehmitools-main.service @@ -0,0 +1,21 @@ +[Unit] +Description=boehmitools Main +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=michael +Group=michael +WorkingDirectory=/home/michael/src/boehmitools-core +EnvironmentFile=/home/michael/runtime/main/.env +Environment=BOEHMITOOLS_PLUGINS=/home/michael/src/boehmitools-main/plugins +Environment=BOEHMITOOLS_DATA=/home/michael/runtime/main/data +Environment=HOST=0.0.0.0 +Environment=PORT=8102 +ExecStart=/home/michael/venvs/boehmitools-main/bin/python /home/michael/src/boehmitools-core/run.py +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/local/instance.env.example b/deploy/local/instance.env.example new file mode 100644 index 0000000..45451fc --- /dev/null +++ b/deploy/local/instance.env.example @@ -0,0 +1,5 @@ +# Keine echten Secrets committen. +HOST=0.0.0.0 +PORT=8102 +BOEHMITOOLS_PLUGINS=/home/michael/src/boehmitools-main/plugins +BOEHMITOOLS_DATA=/home/michael/runtime/main/data diff --git a/deploy/local/start-main.sh.example b/deploy/local/start-main.sh.example new file mode 100755 index 0000000..1756aef --- /dev/null +++ b/deploy/local/start-main.sh.example @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +set -a +source /home/michael/runtime/main/.env +set +a +export BOEHMITOOLS_PLUGINS=/home/michael/src/boehmitools-main/plugins +export BOEHMITOOLS_DATA=/home/michael/runtime/main/data +export HOST="0.0.0.0" +export PORT="8102" +cd /home/michael/src/boehmitools-core +exec /home/michael/venvs/boehmitools-main/bin/python run.py diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 0000000..c7d8395 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,295 @@ +# Plugins — die Bauanleitung + +Ein Tool wird zum Plugin, indem sein Ordner hier abgelegt wird. Beim Start liest +die Suite jede `plugin.json`, lädt das Backend und hängt es unter `/plugins/` +ein. Das Dashboard listet es danach automatisch auf. Es gibt keine zentrale +Liste, in die man ein Tool eintragen müsste. + +``` +plugins/ +└── mein-tool/ + ├── plugin.json Pflicht — Metadaten + ├── backend.py Pflicht — create_app(ctx) gibt eine ASGI-App zurück + ├── static/ + │ └── index.html die Oberfläche + └── … der Rest des Tools, unverändert +``` + +--- + +## 1. plugin.json + +```json +{ + "id": "mein-tool", + "name": "Mein Tool", + "summary": "Eine Zeile für den Tool-Umschalter", + "description": "Zwei bis drei Zeilen für die Dashboard-Karte.", + "icon": "🔧", + "category": "Tandoor", + "version": "1.0.0", + "entrypoint": "backend:create_app", + "order": 50, + "requires": ["tandoor"], + "features": ["Stichpunkt eins", "Stichpunkt zwei"], + "docs": "TOOL-README.md", + "enabled": true +} +``` + +| Feld | Bedeutung | +|---|---| +| `id` | identisch zum Ordnernamen, bestimmt Mount-Pfad und Datenordner | +| `category` | gruppiert die Karten im Dashboard | +| `order` | Sortierung (kleiner = weiter oben) | +| `requires` | `tandoor`, `openai` — erscheint als Kennzeichen auf der Karte | +| `mount` | optional, Standard ist `/plugins/` | +| `enabled` | auf `false` setzen, um ein Tool vorübergehend stillzulegen | + +## 2. backend.py + +```python +from fastapi import FastAPI +from fastapi.responses import FileResponse + +def create_app(ctx): + app = FastAPI(title=ctx.meta.name) + + @app.get("/") + def index(): + return FileResponse(ctx.path("static", "index.html")) + + @app.get("/api/state") + def state(): + return {"tandoor": ctx.settings.status()["tandoor"]} + + return app +``` + +Der Kontext `ctx`: + +| Attribut | Inhalt | +|---|---| +| `ctx.id`, `ctx.meta` | ID und Metadaten aus der `plugin.json` | +| `ctx.dir`, `ctx.path(*teile)` | Pfade **im** Plugin-Ordner (nur lesen) | +| `ctx.data_dir` | eigener Datenordner `data//` (hier schreiben) | +| `ctx.settings` | zentrale Zugänge: `.get("TANDOOR_URL")`, `.status()`, `.tool_env()` | +| `ctx.jobs` | Job-Runner für Kommandozeilen-Tools | +| `ctx.mount` | Mount-Präfix, z. B. `/plugins/mein-tool` | + +Zurückgeben lässt sich jede ASGI-App. Eine **Flask**-App wird eingepackt: + +```python +from a2wsgi import WSGIMiddleware +return WSGIMiddleware(flask_app) +``` + +### Namenskollisionen vermeiden + +Gewachsene Tools benutzen naheliegende Modulnamen (`app.py`, `storage.py`). +Damit sich zwei Plugins nicht ins Gehege kommen, werden ihre Module über +`core.loader` unter eindeutigem Namen geladen — statt mit `import app`: + +```python +from core.loader import load_module, load_package, load_submodule + +modul = load_module(ctx.path("app.py"), f"btp_{ctx.id}_app") # app.py +paket = load_package(ctx.path("app"), f"btp_{ctx.id}") # app/ +main = load_submodule(ctx.path("app"), f"btp_{ctx.id}", "main") # app/main.py +``` + +## 3. Die Oberfläche + +Damit ein Tool aussieht wie der Rest der Suite, bindet seine `index.html` das +gemeinsame Design-System ein und bringt selbst nur noch Layout mit: + +```html + + + + + +
+``` + +`boehmi.js` ergänzt die App-Bar mit Tool-Umschalter und Design-Wechsel und +stellt bereit: + +| Aufruf | Zweck | +|---|---| +| `BT.url("/api/x")` | Plugin-Pfad → vollständige URL (Mount-Präfix) | +| `BT.api("/api/x", {method:"POST", body:…})` | fetch mit JSON- und Fehlerbehandlung | +| `BT.toast("Text", "ok"\|"err")` | Rückmeldung | +| `BT.escape(text)` | HTML-sicher ausgeben | +| `BT.plugins`, `BT.ready` | Liste aller Tools, Promise nach dem Aufbau | + +**Wichtig:** API-Pfade nie fest verdrahten (`fetch("/api/x")`), sondern immer +über `BT.url()` bzw. `BT.api()` — sonst zeigt das Tool auf den Host statt auf +sich selbst. + +### Bausteine aus `/shared/boehmi.css` + +`bt-main` (`.wide`, `.narrow`) · `bt-pagehead` · `bt-card` · `bt-btnrow` · +`bt-row` / `bt-grid2` / `bt-grid3` · `bt-tabs` + `bt-tab` · `bt-badge` +(`ok warn err info accent`) · `bt-notice` (`ok warn err info`) · `bt-status` · +`bt-item` · `bt-table` · `bt-log` · `bt-empty` · `bt-muted` · `bt-hidden` + +Buttons und Formularfelder sind direkt gestylt — ` + +
Bereit.
+ +
+
+
+
Noch kein Rezept analysiert.
+
+ +
+
+ + +
+ +
+
Noch keine Zuordnungen.
+
+ +
+ +
+ +
+ + + +
+
+
+ + + + + diff --git a/plugins/tandoor-ai-import/tests/integration_mock.py b/plugins/tandoor-ai-import/tests/integration_mock.py new file mode 100644 index 0000000..2b1861f --- /dev/null +++ b/plugins/tandoor-ai-import/tests/integration_mock.py @@ -0,0 +1,85 @@ +from __future__ import annotations +import json, os, threading +from copy import deepcopy +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import urlparse +import sys +from pathlib import Path +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +from app.models import RecipeSpec +from app.tandoor_service import resolve_recipe, import_recipe + +state = { + 'foods': [ + {'id': 1, 'name': 'Kartoffel', 'plural_name': 'Kartoffeln', 'properties': [{'x':1}]}, + {'id': 2, 'name': 'Schinken', 'plural_name': 'Schinken', 'properties': []}, + {'id': 3, 'name': 'Zwiebeln', 'plural_name': 'Zwiebeln', 'properties': []}, + ], + 'units': [{'id': 10, 'name': 'g', 'plural_name': 'g'}], + 'keywords': [], + 'recipe': None, + 'next_food_id': 100, +} + +class H(BaseHTTPRequestHandler): + def sendj(self, x, status=200): + b=json.dumps(x).encode(); self.send_response(status); self.send_header('Content-Type','application/json'); self.send_header('Content-Length',str(len(b))); self.end_headers(); self.wfile.write(b) + def do_GET(self): + p=urlparse(self.path).path + if p == '/api/food/': return self.sendj({'results':state['foods'],'next':None}) + if p == '/api/unit/': return self.sendj({'results':state['units'],'next':None}) + if p == '/api/keyword/': return self.sendj({'results':state['keywords'],'next':None}) + if p == '/api/recipe/': return self.sendj({'results':[],'next':None}) + if p == '/api/recipe/999/': return self.sendj(state['recipe']) + if p.startswith('/api/food/'): + oid=int(p.rstrip('/').split('/')[-1]); obj=next(x for x in state['foods'] if x['id']==oid); return self.sendj(obj) + if p.startswith('/api/unit/'): + oid=int(p.rstrip('/').split('/')[-1]); obj=next(x for x in state['units'] if x['id']==oid); return self.sendj(obj) + return self.sendj({'detail':'not found'},404) + def do_POST(self): + n=int(self.headers.get('Content-Length','0')); data=json.loads(self.rfile.read(n) or b'{}') + if self.path == '/api/food/': + obj={'id':state['next_food_id'], **data}; state['next_food_id'] += 1; state['foods'].append(obj); return self.sendj(obj,201) + if self.path == '/api/recipe/': + r=deepcopy(data); r['id']=999; iid=2000; sid=1000 + for st in r['steps']: + st['id']=sid; sid += 1 + for ing in st['ingredients']: + ing['id']=iid; iid += 1 + fid=ing['food']['id']; ing['food']=next(x for x in state['foods'] if x['id']==fid) + if ing.get('unit'): + uid=ing['unit']['id']; ing['unit']=next(x for x in state['units'] if x['id']==uid) + state['recipe']=r; return self.sendj(r,201) + return self.sendj({'detail':'not found'},404) + def do_PUT(self): return self.sendj({'image':'ok'}) + def do_DELETE(self): self.send_response(204); self.end_headers() + def log_message(self,*args): pass + +srv=HTTPServer(('127.0.0.1',0),H); threading.Thread(target=srv.serve_forever,daemon=True).start() +os.environ['TANDOOR_URL']=f'http://127.0.0.1:{srv.server_port}' +os.environ['TANDOOR_TOKEN']='x' +os.environ['TANDOOR_AUTH_SCHEME']='Bearer' + +raw={ + 'schema_version':1,'name':'Bratkartoffeln','description':'','source_url':'https://example.com/r','image_url':None, + 'servings':2,'servings_text':'2 Portionen','working_time':10,'waiting_time':30,'keywords':[],'confidence':'high','warnings':[], + 'steps':[{'name':'Bratkartoffeln','instruction':'Braten.','time':30,'ingredients':[ + {'food_name':'Kartoffeln','preferred_food_id':None,'create_food':False,'food_plural_name':None,'amount':500,'amount_max':None,'unit_name':'g','preferred_unit_id':None,'create_unit':False,'unit_plural_name':None,'note':'','no_amount':False,'original_text':'500 g Kartoffeln'}, + {'food_name':'Kochschinken','preferred_food_id':None,'create_food':False,'food_plural_name':None,'amount':100,'amount_max':None,'unit_name':'g','preferred_unit_id':None,'create_unit':False,'unit_plural_name':None,'note':'','no_amount':False,'original_text':'100 g Kochschinken'}, + {'food_name':'Zauberkrume','preferred_food_id':None,'create_food':True,'food_plural_name':'Zauberkrumen','amount':20,'amount_max':None,'unit_name':'g','preferred_unit_id':None,'create_unit':False,'unit_plural_name':None,'note':'','no_amount':False,'original_text':'20 g Zauberkrume'}, + ]}] +} +recipe=RecipeSpec.model_validate(raw) +res=resolve_recipe(recipe) +assert not res['blocking'], res +m=res['mappings'] +assert m[0]['food']['resolved']['name']=='Kartoffel', m[0] +assert m[1]['food']['resolved']['name']=='Schinken', m[1] +assert m[2]['food']['status']=='create', m[2] +out=import_recipe(recipe,res,import_image=False,force_duplicate=False) +assert out['status']=='imported', out +assert any(x['name']=='Zauberkrume' for x in out['created_objects']), out +assert state['recipe']['steps'][0]['ingredients'][2]['food']['name']=='Zauberkrume' +print('Integrationstest erfolgreich:', out['recipe_url']) +srv.shutdown() diff --git a/plugins/tandoor-ai-import/tests/selftest.py b/plugins/tandoor-ai-import/tests/selftest.py new file mode 100644 index 0000000..699c299 --- /dev/null +++ b/plugins/tandoor-ai-import/tests/selftest.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import json +import sys +from copy import deepcopy +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from app.models import RECIPE_JSON_SCHEMA, RecipeSpec +from app.quality import local_quality_warnings +from app.source_extractor import extract_from_html +from app.tandoor_service import ( + _materialize_created_objects, + resolve_object, + search_variants, +) + + +class FakeClient: + def __init__(self): + self.objects = { + "food": [ + { + "id": 10, + "name": "Kartoffel", + "plural_name": "Kartoffeln", + "properties": [{"x": 1}], + }, + {"id": 11, "name": "Zwiebeln", "plural_name": "Zwiebeln"}, + {"id": 12, "name": "Schinken", "plural_name": "Schinken"}, + ], + "unit": [ + {"id": 20, "name": "g", "plural_name": "g"}, + {"id": 21, "name": "TL", "plural_name": "TL"}, + ], + "keyword": [], + } + self.next_id = 100 + self.posts = [] + self.deleted = [] + + def list_objects(self, endpoint, force_refresh=False): + return deepcopy(self.objects.get(endpoint, [])) + + def get_json(self, path): + parts = path.strip("/").split("/") + endpoint = parts[-2] + object_id = int(parts[-1]) + for obj in self.objects.get(endpoint, []): + if obj["id"] == object_id: + return deepcopy(obj) + raise RuntimeError("not found") + + def post_json(self, path, payload): + endpoint = path.strip("/").split("/")[-1] + obj = {"id": self.next_id, **payload} + self.next_id += 1 + self.objects.setdefault(endpoint, []).append(obj) + self.posts.append((endpoint, deepcopy(payload))) + return deepcopy(obj) + + def forget_cache(self, endpoint): + pass + + def delete(self, path): + self.deleted.append(path) + + +def test_json_ld_extraction(): + html = """ + + Test + +

Testrezept

Mischen.

+ """ + source = extract_from_html("https://example.com/rezept.html", html) + assert source.title == "Testrezept" + assert source.structured_recipe["@type"] == "Recipe" + assert source.image_url == "https://example.com/image.jpg" + + +def test_recipe_validation(): + payload = json.loads((ROOT / "examples" / "crispy-chickpeas.json").read_text()) + recipe = RecipeSpec.model_validate(payload) + assert recipe.name.startswith("Knusprige") + assert local_quality_warnings(recipe) == [] + + +def test_suspicious_unit(): + payload = json.loads((ROOT / "examples" / "crispy-chickpeas.json").read_text()) + payload["steps"][0]["ingredients"][0]["unit_name"] = "Limette" + recipe = RecipeSpec.model_validate(payload) + warnings = local_quality_warnings(recipe) + assert any(item["code"] == "suspicious_unit" for item in warnings) + + +def test_schema_has_strict_objects(): + assert RECIPE_JSON_SCHEMA["additionalProperties"] is False + ingredient = ( + RECIPE_JSON_SCHEMA["properties"]["steps"]["items"]["properties"] + ["ingredients"]["items"] + ) + assert ingredient["additionalProperties"] is False + required = set(ingredient["required"]) + assert {"create_food", "create_unit", "food_plural_name", "unit_plural_name"} <= required + + +def test_german_word_variants_and_preselection(): + assert "kartoffel" in search_variants("Kartoffeln") + assert "zwiebeln" in search_variants("Zwiebel") + + client = FakeClient() + potato = resolve_object( + client, + "food", + "Kartoffeln", + None, + object_type="Food", + ) + assert potato["blocking"] is False + assert potato["resolved"]["id"] == 10 + assert potato["selected_id"] == 10 + + onion = resolve_object( + client, + "food", + "Zwiebel", + None, + object_type="Food", + ) + assert onion["blocking"] is False + assert onion["resolved"]["id"] == 11 + + +def test_missing_can_be_explicitly_created(): + client = FakeClient() + missing = resolve_object( + client, + "food", + "Kochschinkenwürfel", + None, + object_type="Food", + ) + # Schinken ist als Teilwort-Kandidat vorhanden und wird vorausgewählt. + assert missing["blocking"] is False + assert missing["candidates"] + + create = resolve_object( + client, + "food", + "Tempeh-Crunch", + None, + object_type="Food", + create_requested=True, + plural_name="Tempeh-Crunch", + ) + assert create["status"] == "create" + assert create["blocking"] is False + + +def test_create_objects_are_materialized_for_recipe_payload(): + client = FakeClient() + payload = { + "steps": [ + { + "ingredients": [ + { + "food": { + "name": "Tempeh-Crunch", + "plural_name": None, + "create": True, + "endpoint": "food", + }, + "unit": { + "name": "Portion", + "plural_name": "Portionen", + "create": True, + "endpoint": "unit", + }, + } + ] + } + ] + } + final, created = _materialize_created_objects(client, payload) + ingredient = final["steps"][0]["ingredients"][0] + assert ingredient["food"]["id"] == 100 + assert ingredient["unit"]["id"] == 101 + assert len(created) == 2 + assert client.posts == [ + ("food", {"name": "Tempeh-Crunch"}), + ("unit", {"name": "Portion", "plural_name": "Portionen"}), + ] + + +if __name__ == "__main__": + test_json_ld_extraction() + test_recipe_validation() + test_suspicious_unit() + test_schema_has_strict_objects() + test_german_word_variants_and_preselection() + test_missing_can_be_explicitly_created() + test_create_objects_are_materialized_for_recipe_payload() + print("Alle Selbsttests erfolgreich.") diff --git a/plugins/tandoor-categories/TOOL-README.md b/plugins/tandoor-categories/TOOL-README.md new file mode 100644 index 0000000..c6a8d9c --- /dev/null +++ b/plugins/tandoor-categories/TOOL-README.md @@ -0,0 +1,37 @@ +# Zutaten-Kategorien + +Ordnet Zutaten ohne **Supermarkt-Kategorie** einer der in Tandoor bereits +vorhandenen Kategorien zu. Neue Kategorien werden nicht angelegt. + +## Ablauf + +1. **Prüfen** liest die vorhandenen Kategorien und alle Zutaten. Zutaten ohne + Kategorie stehen oben und sind hervorgehoben. +2. **Einordnen lassen** (ChatGPT, optional) weist jeder noch nicht eingeordneten + Zutat die am besten passende vorhandene Kategorie zu und hakt sie an. Passt + keine, bleibt die Zutat offen. Das Modell kommt aus den Einstellungen + (`OPENAI_MODEL`), der Schlüssel aus `OPENAI_API_KEY`. +3. Du prüfst, korrigierst per Auswahlfeld und hakst an, was geschrieben werden + soll. Geschrieben wird nur, was sich vom Ist-Stand unterscheidet. +4. **Zuweisen** schreibt die Kategorien nach Tandoor. Vorher sichern, per + Trockenübung testen. Über **Sicherungen** lässt sich jeder Lauf exakt + zurückspielen. + +## Sicherheit + +* Es werden nur Kategorien vergeben, die es in Tandoor schon gibt — die + Oberfläche kann keine fremde ID unterschieben (das Backend prüft gegen die + im Plan bekannten Kategorien). +* Kategorie-Zuweisungen sind nicht destruktiv und voll rückspielbar. +* Wie bei den Einheiten wird robust geschrieben: zuerst per PATCH, bei einem + Server-500 per vollständigem PUT. + +## Kommandozeile + +``` +categories.py pruefen +categories.py vorschlagen --plan [--alle] +categories.py anwenden --plan [--apply] +categories.py zurueck --lauf [--apply] +categories.py probe +``` diff --git a/plugins/tandoor-categories/backend.py b/plugins/tandoor-categories/backend.py new file mode 100644 index 0000000..1338694 --- /dev/null +++ b/plugins/tandoor-categories/backend.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +""" +Plugin-Adapter für „Zutaten-Kategorien“. + +Alles Verändernde läuft über tool/categories.py im Job-Runner, nie direkt aus einer +Anfrage. Die Auswahl steht vorher in einer Plandatei, die sich ansehen, ändern +und im Zweifel einfach nicht anwenden lässt. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from core.jobs import job_router +from core.backups import backup_router + +TOOL = "categories.py" + + +class EditRequest(BaseModel): + accepted: list[dict[str, Any]] + + +class PlanRef(BaseModel): + plan: str + + +class ApplyRequest(BaseModel): + plan: str + apply: bool = True + + +class PrefillRequest(BaseModel): + plan: str + model: str | None = None + alle: bool = False + + +class ProbeRequest(BaseModel): + model: str | None = None + + +def create_app(ctx): + tool = ctx.path("tool", TOOL) + plans_dir = ctx.data_dir / "plaene" + plans_dir.mkdir(parents=True, exist_ok=True) + + def tool_env() -> dict: + env = ctx.settings.tool_env() + env["DATA_DIR"] = str(ctx.data_dir) + return env + + def resolve_plan(name: str) -> Path: + candidate = plans_dir / Path(name).name + if candidate.suffix != ".json" or not candidate.is_file(): + raise HTTPException(404, f"Plandatei „{name}“ gibt es nicht.") + return candidate + + app = FastAPI(title=ctx.meta.name, docs_url="/api/docs", redoc_url=None) + + @app.get("/", include_in_schema=False) + def index(): + return FileResponse(ctx.path("static", "index.html")) + + @app.get("/api/state") + def state() -> dict[str, Any]: + running = [j.info() for j in ctx.jobs.running(ctx.id)] + return { + "plans": sorted((p.name for p in plans_dir.glob("*.json")), reverse=True), + "tandoor": ctx.settings.status()["tandoor"], + "openai": ctx.settings.status()["openai"], + "running": running[0] if running else None, + } + + @app.get("/api/plans/{name}") + def read_plan(name: str) -> dict[str, Any]: + return json.loads(resolve_plan(name).read_text(encoding="utf-8")) + + @app.post("/api/plans/{name}") + def save_plan(name: str, request: EditRequest) -> dict[str, Any]: + """ + Übernimmt die Auswahl aus der Oberfläche. Eng gehalten: je Zutat lassen + sich nur `accept` und `proposed_id` ändern, und `proposed_id` muss eine + der im Plan bekannten Kategorien sein (oder null). So kann die Oberfläche + keine fremde ID unterschieben. + """ + file = resolve_plan(name) + plan = json.loads(file.read_text(encoding="utf-8")) + valid = {c["id"]: c["name"] for c in plan.get("categories", [])} + by_aid = {f.get("aid"): f for f in plan.get("foods", []) if f.get("aid")} + + gesetzt = 0 + for wish in request.accepted: + food = by_aid.get(wish.get("aid")) + if food is None: + continue + if "proposed_id" in wish: + pid = wish.get("proposed_id") + if pid is None: + food["proposed_id"] = None + food["proposed_name"] = "" + elif pid in valid: + food["proposed_id"] = pid + food["proposed_name"] = valid[pid] + # unbekannte ID wird ignoriert + food["accept"] = bool(wish.get("accept")) + gesetzt += 1 + + file.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8") + offen = sum(1 for f in plan["foods"] + if f.get("accept") and f.get("proposed_id") != f.get("current_id")) + return {"ok": True, "gesetzt": gesetzt, "zuweisungen": offen} + + # ---------------------------------------------------------------- Läufe + + @app.post("/api/run/pruefen") + async def run_scan() -> dict[str, Any]: + if ctx.jobs.running(ctx.id): + raise HTTPException(409, "Es läuft bereits etwas.") + if not ctx.settings.status()["tandoor"]: + raise HTTPException(400, "Tandoor-URL und Token fehlen.") + job = await ctx.jobs.start( + plugin=ctx.id, label="Kategorien prüfen", + argv=[sys.executable, str(tool), "pruefen"], + cwd=ctx.data_dir, env=tool_env(), + ) + return job.info() + + @app.post("/api/run/vorschlagen") + async def run_prefill(request: PrefillRequest) -> dict[str, Any]: + if ctx.jobs.running(ctx.id): + raise HTTPException(409, "Es läuft bereits etwas.") + if not ctx.settings.status()["openai"]: + raise HTTPException(400, "OpenAI-Key fehlt – siehe Einstellungen.") + file = resolve_plan(request.plan) + argv = [sys.executable, str(tool), "vorschlagen", "--plan", str(file)] + if request.model: + argv += ["--model", request.model] + if request.alle: + argv.append("--alle") + job = await ctx.jobs.start( + plugin=ctx.id, label=f"KI ordnet ein: {file.stem}", + argv=argv, cwd=ctx.data_dir, env=tool_env(), + ) + return job.info() + + @app.post("/api/run/anwenden") + async def run_apply(request: ApplyRequest) -> dict[str, Any]: + if ctx.jobs.running(ctx.id): + raise HTTPException(409, "Es läuft bereits etwas.") + if not ctx.settings.status()["tandoor"]: + raise HTTPException(400, "Tandoor-URL und Token fehlen.") + file = resolve_plan(request.plan) + argv = [sys.executable, str(tool), "anwenden", "--plan", str(file), + "--continue-on-error"] + if request.apply: + argv.append("--apply") + job = await ctx.jobs.start( + plugin=ctx.id, label=("Kategorien zuweisen" if request.apply else "Trockenübung") + + f": {file.stem}", + argv=argv, cwd=ctx.data_dir, env=tool_env(), + ) + return job.info() + + @app.post("/api/run/probe") + async def run_probe(request: ProbeRequest) -> dict[str, Any]: + if ctx.jobs.running(ctx.id): + raise HTTPException(409, "Es läuft bereits etwas.") + if not ctx.settings.status()["openai"]: + raise HTTPException(400, "OpenAI-Key fehlt – siehe Einstellungen.") + argv = [sys.executable, str(tool), "probe"] + if request.model: + argv += ["--model", request.model] + job = await ctx.jobs.start( + plugin=ctx.id, label="OpenAI-Verbindung testen", + argv=argv, cwd=ctx.data_dir, env=tool_env(), + ) + return job.info() + + def restore_argv(run: str, apply: bool, force: bool) -> list[str]: + argv = [sys.executable, str(tool), "zurueck", "--lauf", run] + if apply: + argv.append("--apply") + return argv + + app.include_router(backup_router(ctx, "laeufe", restore_argv)) + app.include_router(job_router(ctx.jobs, ctx.id)) + return app diff --git a/plugins/tandoor-categories/plugin.json b/plugins/tandoor-categories/plugin.json new file mode 100644 index 0000000..2a9c76c --- /dev/null +++ b/plugins/tandoor-categories/plugin.json @@ -0,0 +1,20 @@ +{ + "id": "tandoor-categories", + "name": "Zutaten-Kategorien", + "summary": "Fehlende Supermarkt-Kategorien per KI zuweisen", + "description": "Liest die vorhandenen Kategorien und die Zutaten und lässt ChatGPT die noch nicht eingeordneten Zutaten einer passenden vorhandenen Kategorie zuordnen. Nichts wird automatisch geschrieben; jede Zuweisung ist freizugeben, gesichert und rückspielbar.", + "icon": "🏷", + "category": "Tandoor", + "version": "1.0.0", + "entrypoint": "backend:create_app", + "order": 48, + "requires": [ + "tandoor" + ], + "features": [ + "Ordnet nur in bereits vorhandene Kategorien ein", + "KI schlägt vor, du gibst frei", + "Nicht destruktiv und voll rückspielbar" + ], + "docs": "TOOL-README.md" +} diff --git a/plugins/tandoor-categories/static/index.html b/plugins/tandoor-categories/static/index.html new file mode 100644 index 0000000..1bdfd48 --- /dev/null +++ b/plugins/tandoor-categories/static/index.html @@ -0,0 +1,330 @@ + + + + + +Zutaten-Kategorien + + + + + + + +
+
+

🏷 Zutaten-Kategorien

+

Ordnet Zutaten ohne Supermarkt-Kategorie einer der vorhandenen + Kategorien zu — auf Wunsch per ChatGPT vorgeschlagen. Du gibst frei, + nichts wird automatisch geschrieben.

+
+ +
+ +
+ + + + +
+ + +
+
+
+
+

Bestandsaufnahme

+

Liest die vorhandenen Kategorien + und alle Zutaten. Verändert nichts.

+
+ +
+
+ + +
+
Bereit.
+
+
+
Noch nicht geprüft. Oben auf „Jetzt prüfen“.
+
+ + +
+
+

Einordnen per KI (ChatGPT)

+

Lässt ChatGPT jede noch nicht eingeordnete Zutat einer der + vorhandenen Kategorien zuordnen. Passt keine, bleibt die Zutat + offen. Nur ein Vorschlag — prüfen und freigeben unter „Übernehmen“.

+ +
+ + + + +
+
Bereit.
+
+
+
+ + +
+
Erst prüfen (Tab 1).
+ +
+ + +
+
+ + + + diff --git a/plugins/tandoor-categories/tool/categories.py b/plugins/tandoor-categories/tool/categories.py new file mode 100644 index 0000000..26c5a0b --- /dev/null +++ b/plugins/tandoor-categories/tool/categories.py @@ -0,0 +1,402 @@ +# -*- coding: utf-8 -*- +""" +Zutaten-Kategorien — Supermarkt-Kategorien den Zutaten zuweisen. + +Liest die vorhandenen Kategorien und die Zutaten, lässt (optional) ChatGPT den +noch nicht eingeordneten Zutaten eine der VORHANDENEN Kategorien zuweisen und +schreibt die Auswahl nach Freigabe zurück. Nichts wird automatisch ausgeführt; +jede Änderung ist gesichert und rückspielbar. + +Befehle: + pruefen Kategorien + Zutaten lesen, Plan schreiben + vorschlagen ChatGPT ordnet die kategorielosen Zutaten ein (Plan --plan) + anwenden Freigegebene Zuweisungen schreiben (Plan --plan [--apply]) + zurueck Einen Lauf zurückspielen (--lauf) + probe OpenAI-Verbindung testen +""" + +from __future__ import annotations + +import argparse +import json +import os +import secrets +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +_PARENTS = Path(__file__).resolve().parents +SUITE_ROOT = _PARENTS[3] if len(_PARENTS) > 3 else Path.cwd() +if str(SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(SUITE_ROOT)) + +from core.tandoor import TandoorClient, TandoorError # noqa: E402 +from core import backups # noqa: E402 +from core import ai # noqa: E402 + +BATCH = 40 + + +def out(text: str = "") -> None: + print(text, flush=True) + + +def data_dir() -> Path: + configured = os.environ.get("DATA_DIR", "").strip() + base = Path(configured) if configured else SUITE_ROOT / "data" / "tandoor-categories" + base.mkdir(parents=True, exist_ok=True) + return base + + +def stamp() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def make_client(args: argparse.Namespace) -> TandoorClient: + base = args.base_url or os.environ.get("TANDOOR_URL", "") + token = args.token or os.environ.get("TANDOOR_TOKEN", "") + scheme = args.auth_scheme or os.environ.get("TANDOOR_AUTH_SCHEME", "Bearer") + return TandoorClient(base, token, auth_scheme=scheme, + verify=not args.insecure, timeout=args.timeout) + + +# ------------------------------------------------------------------ Prüfen + +def scan(client: TandoorClient) -> dict[str, Any]: + out("Kategorien werden gelesen …") + categories = [{"id": c["id"], "name": c.get("name") or ""} + for c in client.list_objects("supermarket-category")] + out(f" {len(categories)} Kategorien vorhanden.") + + out("Zutaten werden gelesen …") + foods = client.list_objects("food") + counter = {"n": 0} + + def new_aid() -> str: + counter["n"] += 1 + return f"c{counter['n']:04d}-{secrets.token_hex(2)}" + + eintraege = [] + for f in foods: + sc = f.get("supermarket_category") or None + cur_id = sc.get("id") if isinstance(sc, dict) else None + cur_name = sc.get("name") if isinstance(sc, dict) else "" + eintraege.append({ + "aid": new_aid(), + "id": f["id"], + "name": f.get("name"), + "current_id": cur_id, + "current_name": cur_name or "", + # Vorschlag = aktueller Stand, bis die KI etwas anderes sagt. + "proposed_id": cur_id, + "proposed_name": cur_name or "", + "ai": False, + "accept": False, + }) + # Ohne Kategorie zuerst, dann alphabetisch. + eintraege.sort(key=lambda e: (bool(e["current_id"]), (e["name"] or "").casefold())) + ohne = sum(1 for e in eintraege if not e["current_id"]) + out(f" {len(eintraege)} Zutaten, davon {ohne} ohne Kategorie.") + + return { + "created_at": datetime.now(timezone.utc).isoformat(), + "tandoor": client.base_url, + "categories": categories, + "foods": eintraege, + } + + +def print_scan(plan: dict[str, Any]) -> None: + ohne = [f for f in plan["foods"] if not f["current_id"]] + out() + out("─" * 60) + out(f" {len(plan['categories'])} Kategorien · {len(plan['foods'])} Zutaten · " + f"{len(ohne)} ohne Kategorie") + out("─" * 60) + for f in ohne[:20]: + out(f" {f['name']} [{f['id']}]") + if len(ohne) > 20: + out(f" … und {len(ohne) - 20} weitere ohne Kategorie") + + +# ------------------------------------------------------------- KI-Vorschlag + +def vorschlagen(plan: dict[str, Any], model: str, alle: bool) -> int: + kats = plan["categories"] + if not kats: + out("Keine Kategorien vorhanden — in Tandoor zuerst welche anlegen.") + return 0 + katnamen = [c["name"] for c in kats] + by_name = {c["name"].casefold(): c for c in kats} + + ziele = plan["foods"] if alle else [f for f in plan["foods"] if not f["current_id"]] + if not ziele: + out("Nichts zu tun — alle Zutaten haben bereits eine Kategorie.") + return 0 + + system = ( + "Du ordnest Lebensmittel den Abteilungen eines Supermarkts zu. Wähle für " + "jede Zutat GENAU EINE der vorgegebenen Kategorien — die am besten " + "passende. Erfinde keine neuen Kategorien. Passt keine sinnvoll, gib für " + "diese Zutat null zurück. Antworte ausschließlich mit JSON." + ) + gesetzt = 0 + for start in range(0, len(ziele), BATCH): + teil = ziele[start:start + BATCH] + namen = [{"id": f["aid"], "zutat": f["name"]} for f in teil] + user = ( + "Vorhandene Kategorien: " + json.dumps(katnamen, ensure_ascii=False) + + "\n\nGib ein JSON-Objekt zurück: Schlüssel ist die „id“, Wert ist " + "der exakte Kategoriename aus der Liste (oder null).\n\nZutaten:\n" + + json.dumps(namen, ensure_ascii=False) + ) + antwort = ai.chat_json( + [{"role": "system", "content": system}, {"role": "user", "content": user}], + model, + ) + if not isinstance(antwort, dict): + continue + for f in teil: + wahl = antwort.get(f["aid"]) + if not isinstance(wahl, str): + continue + cat = by_name.get(wahl.strip().casefold()) + if not cat: + continue + f["proposed_id"] = cat["id"] + f["proposed_name"] = cat["name"] + f["ai"] = True + if cat["id"] != f["current_id"]: + f["accept"] = True + gesetzt += 1 + return gesetzt + + +# --------------------------------------------------------------- Anwenden + +def _write_category(client: TandoorClient, food: dict[str, Any], + cat_id: int | None, cat_name: str) -> str: + """ + Setzt supermarket_category robust. Wie bei den Einheiten kann Tandoor bei + einem bloßen Teil-PATCH zicken; deshalb zuerst der übliche PATCH, dann ein + vollständiger PUT (alle Felder erhalten). Rückgabe: verwendete Methode. + """ + url = f"api/food/{food['id']}/" + wert = {"id": cat_id, "name": cat_name} if cat_id else None + try: + client.patch_json(url, {"supermarket_category": wert}) + return "PATCH" + except TandoorError as exc: + letzter = exc + try: + voll = dict(food) + voll["supermarket_category"] = wert + client.put_json(url, voll) + return "PUT" + except TandoorError: + raise letzter + + +def anwenden(client: TandoorClient, plan: dict[str, Any], args: argparse.Namespace) -> int: + ziele = [f for f in plan["foods"] + if f.get("accept") and f.get("proposed_id") != f.get("current_id")] + if not ziele: + out("Nichts angehakt (oder alles unverändert).") + return 0 + + out(f"Modus: {'ANWENDEN' if args.apply else 'TROCKENÜBUNG'}") + out(f"Tandoor: {client.base_url}") + out(f"{len(ziele)} Kategorie-Zuweisungen") + out() + + run = data_dir() / "laeufe" / stamp() + if args.apply: + run.mkdir(parents=True, exist_ok=True) + manifest: dict[str, Any] = { + "created_at": datetime.now(timezone.utc).isoformat(), + "plugin": "tandoor-categories", + "label": "Kategorien zuweisen", + "tandoor": client.base_url, + "mode": "apply" if args.apply else "dry", + "steps": [], + } + + done, failed = 0, 0 + for nummer, f in enumerate(ziele, start=1): + prefix = f"[{nummer}/{len(ziele)}] „{f['name']}“ [{f['id']}] → „{f['proposed_name']}“" + try: + aktuell = client.get_json(f"api/food/{f['id']}/") + except TandoorError: + out(f"{prefix}: übersprungen — Zutat nicht mehr vorhanden") + continue + + alt = aktuell.get("supermarket_category") or None + alt_id = alt.get("id") if isinstance(alt, dict) else None + if alt_id == f["proposed_id"]: + out(f"{prefix}: unverändert — übersprungen") + continue + + if not args.apply: + out(f"{prefix}: würde zuweisen") + done += 1 + continue + + try: + methode = _write_category(client, aktuell, f["proposed_id"], f["proposed_name"]) + except TandoorError as exc: + out(f"{prefix}: FEHLER — {exc}") + failed += 1 + if not args.continue_on_error: + return 1 + continue + + (run / f"food-{f['id']:05d}.json").write_text( + json.dumps(aktuell, ensure_ascii=False, indent=2), encoding="utf-8") + manifest["steps"].append({ + "n": nummer, + "action": "set_category", + "food_id": f["id"], + "food_name": f["name"], + "category_before_id": alt_id, + "category_before_name": (alt.get("name") if isinstance(alt, dict) else "") or "", + "category_after_id": f["proposed_id"], + "category_after_name": f["proposed_name"], + "restore_level": "voll", + "status": "done", + }) + backups.write_manifest(run, manifest) + out(f"{prefix}: ✓ zugewiesen" + (f" [{methode}]" if methode != "PATCH" else "")) + done += 1 + + out() + out("─" * 60) + verb = "zugewiesen" if args.apply else "würden zugewiesen" + out(f" {done} {verb} · {failed} Fehler") + if args.apply and manifest["steps"]: + out(f" Sicherung: {run}") + out("─" * 60) + return 1 if failed else 0 + + +# ------------------------------------------------------------ Zurückspielen + +def restore(client: TandoorClient, args: argparse.Namespace) -> int: + runs = data_dir() / "laeufe" + run = backups.resolve_run(runs, args.lauf) + manifest = backups.read_manifest(run) + if not manifest: + raise SystemExit("Kein manifest.json in diesem Lauf.") + + out(f"Modus: {'ZURÜCKSPIELEN' if args.apply else 'VORSCHAU'}") + zurueck, fehler = 0, 0 + for step in reversed(manifest.get("steps", [])): + if step.get("action") != "set_category": + continue + fid = step["food_id"] + alt_id = step.get("category_before_id") + alt_name = step.get("category_before_name", "") + prefix = f"[{step['n']}] „{step['food_name']}“ [{fid}] zurück" + if not args.apply: + out(f"{prefix}: würde auf „{alt_name or '—'}“ zurücksetzen") + zurueck += 1 + continue + try: + aktuell = client.get_json(f"api/food/{fid}/") + _write_category(client, aktuell, alt_id, alt_name) + out(f"{prefix}: ✓") + zurueck += 1 + except TandoorError as exc: + out(f"{prefix}: FEHLER — {exc}") + fehler += 1 + + if args.apply: + backups.mark_restored(run, {"zurueck": zurueck, "fehler": fehler}) + out(f"{zurueck} zurückgespielt · {fehler} Fehler") + return 1 if fehler else 0 + + +# ------------------------------------------------------------------- CLI + +def load_plan(path: str) -> tuple[Path, dict[str, Any]]: + file = Path(path).expanduser().resolve() + if not file.is_file(): + raise SystemExit(f"Plandatei nicht gefunden: {file}") + return file, json.loads(file.read_text(encoding="utf-8")) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Zutaten-Kategorien") + parser.add_argument("--base-url", default="") + parser.add_argument("--token", default="") + parser.add_argument("--auth-scheme", default="") + parser.add_argument("--timeout", type=float, default=45.0) + parser.add_argument("--insecure", action="store_true") + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("pruefen", help="Kategorien und Zutaten lesen") + + p = sub.add_parser("vorschlagen", help="ChatGPT ordnet Zutaten ein") + p.add_argument("--plan", required=True) + p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5")) + p.add_argument("--alle", action="store_true", + help="auch bereits kategorisierte neu vorschlagen") + + p = sub.add_parser("anwenden", help="Freigegebene Zuweisungen schreiben") + p.add_argument("--plan", required=True) + p.add_argument("--apply", action="store_true") + p.add_argument("--continue-on-error", action="store_true") + + p = sub.add_parser("zurueck", help="Einen Lauf zurückspielen") + p.add_argument("--lauf", required=True) + p.add_argument("--apply", action="store_true") + + p = sub.add_parser("probe", help="OpenAI-Verbindung testen") + p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5")) + + args = parser.parse_args() + + if args.command == "probe": + if not os.environ.get("OPENAI_API_KEY"): + out("OPENAI_API_KEY fehlt — siehe Einstellungen.") + return 2 + ok, meldung = ai.probe(args.model) + out(meldung) + return 0 if ok else 1 + + if args.command == "vorschlagen": + if not os.environ.get("OPENAI_API_KEY"): + out("OPENAI_API_KEY fehlt — siehe Einstellungen.") + return 2 + file, plan = load_plan(args.plan) + out(f"KI ordnet Zutaten ein (Modell {args.model}) …") + n = vorschlagen(plan, args.model, alle=args.alle) + plan["ai_prefilled_at"] = datetime.now(timezone.utc).isoformat() + file.write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + out(f"{n} Zutaten eingeordnet. Bitte prüfen, bevor du anwendest.") + return 0 + + client = make_client(args) + + if args.command == "pruefen": + plan = scan(client) + file = data_dir() / "plaene" / f"{stamp()}.json" + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8") + print_scan(plan) + out() + out(f"Plan: {file}") + return 0 + + if args.command == "anwenden": + _file, plan = load_plan(args.plan) + return anwenden(client, plan, args) + + if args.command == "zurueck": + return restore(client, args) + + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/tandoor-cleanup/TOOL-README.md b/plugins/tandoor-cleanup/TOOL-README.md new file mode 100644 index 0000000..b463f15 --- /dev/null +++ b/plugins/tandoor-cleanup/TOOL-README.md @@ -0,0 +1,141 @@ +# Stammdaten aufräumen + +Führt doppelte Zutaten, Einheiten und Schlagworte zusammen und findet +Karteileichen. + +## Bitte einmal lesen, bevor du es benutzt + +Tandoors `merge` hängt alle Verweise der Quelle auf das Ziel um und **löscht +die Quelle**. Das ist nicht umkehrbar. Im Tandoor-Quelltext steht dazu: + +```python +# TODO these checks could be improved to merge existing properties and +# conversion in a smart way. For now it will just loose them to prevent +# duplicates +if isinstance(source, Food): + source.properties.all().delete() +``` + +Die Nährwerte des Quelleintrags gehen also verloren. Dieses Werkzeug rettet +sie deshalb vorher: Werte, die nur die Quelle hat, werden zuerst ans Ziel +geschrieben. Abschaltbar über das Häkchen bzw. `--ohne-rettung`. + +## Was vorgeschlagen wird — und was nicht + +**Sichere Dubletten** (vorausgewählt) bei praktisch sicheren Fällen: + +* gleicher Name (`Zwiebel` / `zwiebel`, `Olivenöl` / `Olivenoel`) +* Singular/Plural (`Zwiebel` / `Zwiebeln`, `Tomate` / `Tomaten`) + +**Ähnlich** — anhakbar, aber nie vorausgewählt und mit Pflicht zur +Richtungswahl: + +* echte Wort-Teilmengen (`Mehl` / `Mehl Type 550`, `Olivenöl` / + `Olivenöl nativ extra`) — jedes Wort der kürzeren Seite kommt als + ganzes Wort in der längeren vor + +Der Unterschied ist Absicht. `Rote Zwiebel` sieht `Zwiebel` sehr ähnlich, ist +aber etwas anderes — deshalb entscheidet hier der Mensch, ob und in welche +Richtung zusammengeführt wird, statt dass etwas vorausgewählt ist. + +**Bekannte Lücke:** unregelmäßige Plurale mit Umlaut (`Ei` / `Eier`, +`Apfel` / `Äpfel`) erkennt die Heuristik nicht. Die tauchen in keiner der +beiden Listen auf. + +**Löschen** wird grundsätzlich nicht vorausgewählt — manches hebt man +absichtlich auf. + +## Welcher Eintrag bleibt + +Ziel ist der Eintrag, der am meisten zu verlieren hätte, in dieser Reihenfolge: +die meisten Rezepte → die meisten Nährwerte → gepflegter Plural → +Supermarkt-Kategorie → kürzerer Name. + +## Ablauf + +1. **Prüfen** — schreibt eine Plandatei. Verändert nichts. +2. Plan durchsehen, Häkchen setzen. +3. **Trockenübung** — zeigt Zeile für Zeile, was passieren würde. +4. **Ausführen** — erst dann wird es ernst. Vor jedem Schritt wird der + Zustand der betroffenen Einträge gesichert. + +Zusammengeführt wird vor dem Löschen: eine Zusammenführung kann einen Eintrag +erst unbenutzt machen. + +## Ohne Suite + + export TANDOOR_URL=https://kitchen.example.de + export TANDOOR_TOKEN=... + python3 plugins/tandoor-cleanup/tool/cleanup.py pruefen + python3 plugins/tandoor-cleanup/tool/cleanup.py pruefen --arten food + python3 plugins/tandoor-cleanup/tool/cleanup.py ausfuehren \ + --plan data/tandoor-cleanup/plaene/.json # Trockenübung + python3 plugins/tandoor-cleanup/tool/cleanup.py ausfuehren \ + --plan data/tandoor-cleanup/plaene/.json --apply # ernst + +## Dateien + + data/tandoor-cleanup/plaene/.json Plan + data/tandoor-cleanup/laeufe// Sicherungen + ausgeführter Plan + + +## Bessere Ähnlich-Erkennung (Wort-Teilmenge statt Zeichenkette) + +Früher galt schon eine bloße Zeichenketten-Enthaltung als „Teilwort“. Das +erzeugte Fehlpaare wie `Mango` ~ `Mangold`, `Buchweizen` ~ `Buchweizenmehl` +oder `getrocknete Tomate` ~ `getrocknete Tomaten in Öl`. Jetzt zählt nur eine +echte **Wort-Teilmenge**: jedes Wort der kürzeren Seite muss als *ganzes* Wort +in der längeren vorkommen. Dadurch verschwinden diese Fehlpaare, während echte +Untertypen (`Mehl` / `Mehl Type 550`) erhalten bleiben. + +## Plurale pflegen (Zutaten und Einheiten) + +Eigener Abschnitt „Plurale“ je Art — für **Zutaten und Einheiten**. Er zeigt +alle Einträge mit ihrem aktuellen Plural in einem editierbaren Feld; fehlende +stehen oben und sind hervorgehoben. Ein Filterfeld macht große Listen +bedienbar. Bearbeiten hakt die Zeile automatisch an. Geschrieben wird nur, was +sich gegenüber dem Ist-Stand geändert hat — Unverändertes wird übersprungen. +Setzen und Ändern sind nicht destruktiv und **voll rückspielbar**. + +## KI-Vorauswahl für ähnliche Paare (ChatGPT) + +Das Finden der Paare bleibt die Heuristik. Ist beim Prüfen „Ähnliche Paare per +KI vorfiltern“ aktiv (Vorgabe an, sobald ein `OPENAI_API_KEY` hinterlegt ist), +ordnet ChatGPT jedes gefundene Paar in drei Töpfe: + +* **gleich** – zweifelsfrei dasselbe (Schreibvarianten, fehlendes Leerzeichen, + Groß/Klein, Singular/Plural). Wird **angehakt** und die Richtung vorgewählt + (der allgemeinere bzw. korrekt geschriebene Name bleibt). +* **unklar** – könnte dasselbe sein. Wird angezeigt, aber nicht vorgewählt — + du entscheidest. +* **verschieden** – zweifelsfrei verschiedene Sorten/Produkte (Weizenmehl 550 + vs. 1050, Frischkäse vs. körniger Frischkäse). Standardmäßig **ausgeblendet**. + +Ein Filter über der Liste schaltet zwischen **„Nur KI-Treffer“** (gleich + +unklar) und **„Alle“** (auch die verschiedenen) um. + +So sind die eindeutigen Fälle mit einem Blick abgehakt, die strittigen bleiben +sichtbar, und der Lärm ist weg. Nichts wird ausgeführt — du prüfst und startest +selbst; Zusammenführen bleibt gesichert und rückspielbar. Kommandozeile: +`pruefen --ki-filter`. Fällt die KI aus, bleiben alle Paare unverändert stehen. + +## Plurale per KI (ChatGPT) + +Getrennt davon schlägt ChatGPT auf Wunsch die **Plurale** von Zutaten und +Einheiten vor — auch die Umlaut-Fälle (`Apfel` → `Äpfel`), die die Heuristik +nicht kann (`vorbelegen --plan `, Knopf „Plurale vorschlagen“). +Standardmäßig nur dort, wo noch kein Plural gepflegt ist; `--alle-plurale` nimmt +auch bestehende. Einträge, die durch eine angehakte Zusammenführung ohnehin +verschwinden, werden übersprungen. Auch das ist nur ein Vorschlag und voll +rückspielbar. Verbindung prüfen: `probe` bzw. „OpenAI testen“. + +## Hinweis: Einheiten-Plurale und Tandoor-Serverfehler + +Manche Tandoor-Versionen stürzen beim Ändern einer **Einheit** über einen +schlichten Teil-PATCH mit „HTTP 500“ ab (Zutaten sind nicht betroffen). Das +Werkzeug setzt Einheiten-Plurale deshalb robust: zuerst per vollständigem PUT +(anderer Code-Pfad, alle vorhandenen Felder bleiben erhalten), danach mit +mitgesendetem Namen, zuletzt der schlichte Weg. Lehnt der Server alle Wege ab, +werden die restlichen Einheiten-Plurale übersprungen (mit klarer Meldung) statt +sie einzeln durchzuprobieren — dann bitte in der Tandoor-Oberfläche setzen. +Zutaten-Plurale laufen davon unberührt weiter. diff --git a/plugins/tandoor-cleanup/backend.py b/plugins/tandoor-cleanup/backend.py new file mode 100644 index 0000000..9962bb0 --- /dev/null +++ b/plugins/tandoor-cleanup/backend.py @@ -0,0 +1,275 @@ +# -*- coding: utf-8 -*- +""" +Plugin-Adapter für den Stammdaten-Aufräumer. + +Verändernde Schritte laufen ausschließlich über tool/cleanup.py im Job-Runner, +nie direkt aus einer Anfrage heraus. Was ausgeführt wird, steht vorher in einer +Plandatei — die lässt sich ansehen, ändern und im Zweifel einfach nicht +ausführen. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field + +from core.jobs import job_router +from core.backups import backup_router + +TOOL = "cleanup.py" +KINDS = {"food", "unit", "keyword"} + + +class ScanRequest(BaseModel): + kinds: list[str] = Field(default_factory=list) + ki_filter: bool = False + model: str | None = None + insecure: bool = False + + +class ExecuteRequest(BaseModel): + plan: str + apply: bool = False + rescue: bool = True + continue_on_error: bool = True + insecure: bool = False + + +class EditRequest(BaseModel): + """Nur die Häkchen aus der Oberfläche zurückschreiben.""" + accepted: list[dict[str, Any]] + + +class PrefillRequest(BaseModel): + plan: str + model: str | None = None + alle_plurale: bool = False + + +class ProbeRequest(BaseModel): + model: str | None = None + + +def create_app(ctx): + tool = ctx.path("tool", TOOL) + plans_dir = ctx.data_dir / "plaene" + plans_dir.mkdir(parents=True, exist_ok=True) + + def tool_env() -> dict: + """ + Umgebung für den Subprozess. + + Wichtig: DATA_DIR ausdrücklich mitgeben. Ohne das schreibt das Skript + seinen Rückfallpfad neben die Anwendung — im Container ist der + schreibgeschützt, und die Oberfläche würde den Bericht nie finden. + """ + env = ctx.settings.tool_env() + env["DATA_DIR"] = str(ctx.data_dir) + return env + + def resolve_plan(name: str) -> Path: + candidate = plans_dir / Path(name).name + if candidate.suffix != ".json" or not candidate.is_file(): + raise HTTPException(404, f"Plandatei „{name}“ gibt es nicht.") + return candidate + + def clean_plural(value: Any) -> str: + """Pluraltext aus der Oberfläche säubern und begrenzen.""" + text = str(value or "").strip() + return text[:120] + + app = FastAPI(title=ctx.meta.name, docs_url="/api/docs", redoc_url=None) + + @app.get("/", include_in_schema=False) + def index(): + return FileResponse(ctx.path("static", "index.html")) + + @app.get("/api/state") + def state() -> dict[str, Any]: + running = [j.info() for j in ctx.jobs.running(ctx.id)] + return { + "plans": sorted((p.name for p in plans_dir.glob("*.json")), reverse=True), + "tandoor": ctx.settings.status()["tandoor"], + "openai": ctx.settings.status()["openai"], + "running": running[0] if running else None, + "data_dir": str(ctx.data_dir), + } + + @app.get("/api/plans/{name}") + def read_plan(name: str) -> dict[str, Any]: + return json.loads(resolve_plan(name).read_text(encoding="utf-8")) + + @app.post("/api/plans/{name}") + def save_plan(name: str, request: EditRequest) -> dict[str, Any]: + """ + Übernimmt die Auswahl aus der Oberfläche in den Plan. + + Bewusst eng: Aktionen werden über ihre feste `aid` angesprochen, und je + Aktionstyp lassen sich nur bestimmte Felder ändern. So kann die + Oberfläche keine beliebige Zusammenführung unterschieben. + + Erlaubt ist: + alle accept + ähnlich-Merge source_id/target_id — aber NUR die beiden Paar-Mitglieder, + und set_plural (nur Food) + Dubletten-Merge set_plural (nur Food) + set_plural plural_value (Text) + """ + file = resolve_plan(name) + plan = json.loads(file.read_text(encoding="utf-8")) + by_aid = {a.get("aid"): a for a in plan.get("actions", []) if a.get("aid")} + + for wish in request.accepted: + action = by_aid.get(wish.get("aid")) + if action is None: + continue + action["accept"] = bool(wish.get("accept")) + + if action["action"] == "merge" and action.get("origin") == "similar": + # Richtung nur auf genau eines der beiden Paar-Mitglieder. + paar = {action.get("left_id"), action.get("right_id")} + src = wish.get("source_id") + tgt = wish.get("target_id") + if src in paar and tgt in paar and src != tgt: + action["source_id"] = src + action["source_name"] = (action["left_name"] if src == action.get("left_id") + else action["right_name"]) + action["target_id"] = tgt + action["target_name"] = (action["left_name"] if tgt == action.get("left_id") + else action["right_name"]) + elif src is None and tgt is None: + action["source_id"] = action["target_id"] = None + action["source_name"] = action["target_name"] = None + if action["kind"] == "food": + action["set_plural"] = bool(wish.get("set_plural")) + # Der Pluralwert kommt aus dem Scan, je nach Richtung — nicht + # aus beliebiger Eingabe. + if action.get("source_id") == action.get("left_id"): + action["plural_value"] = action.get("plural_left_to_right", "") + else: + action["plural_value"] = action.get("plural_right_to_left", "") + + elif action["action"] == "merge" and action["kind"] == "food": + action["set_plural"] = bool(wish.get("set_plural")) + if wish.get("set_plural") and wish.get("plural_value") is not None: + action["plural_value"] = clean_plural(wish.get("plural_value")) + + elif action["action"] == "set_plural": + if wish.get("plural_value") is not None: + action["plural_value"] = clean_plural(wish.get("plural_value")) + + file.write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8") + angehakt = [a for a in plan["actions"] if a.get("accept")] + return { + "ok": True, + "merges": sum(1 for a in angehakt if a["action"] == "merge"), + "set_plurals": sum(1 for a in angehakt if a["action"] == "set_plural"), + "deletes": sum(1 for a in angehakt if a["action"] == "delete"), + } + + @app.post("/api/run/scan") + async def run_scan(request: ScanRequest) -> dict[str, Any]: + if ctx.jobs.running(ctx.id): + raise HTTPException(409, "Es läuft bereits etwas.") + if not ctx.settings.status()["tandoor"]: + raise HTTPException(400, "Tandoor-URL und Token fehlen – siehe Einstellungen.") + + bad = [k for k in request.kinds if k not in KINDS] + if bad: + raise HTTPException(400, f"Unbekannte Art: {bad}") + + argv = [sys.executable, str(tool)] + if request.insecure: + argv.append("--insecure") + argv.append("pruefen") + if request.kinds: + argv += ["--arten", ",".join(request.kinds)] + if request.ki_filter and ctx.settings.status()["openai"]: + argv.append("--ki-filter") + if request.model: + argv += ["--model", request.model] + + label = "Stammdaten prüfen" + (" · KI-Vorfilter" if request.ki_filter + and ctx.settings.status()["openai"] else "") + job = await ctx.jobs.start( + plugin=ctx.id, label=label, argv=argv, + cwd=ctx.data_dir, env=tool_env(), + ) + return job.info() + + @app.post("/api/run/execute") + async def run_execute(request: ExecuteRequest) -> dict[str, Any]: + if ctx.jobs.running(ctx.id): + raise HTTPException(409, "Es läuft bereits etwas.") + if not ctx.settings.status()["tandoor"]: + raise HTTPException(400, "Tandoor-URL und Token fehlen.") + + file = resolve_plan(request.plan) + argv = [sys.executable, str(tool)] + if request.insecure: + argv.append("--insecure") + argv += ["ausfuehren", "--plan", str(file)] + if request.apply: + argv.append("--apply") + if not request.rescue: + argv.append("--ohne-rettung") + if request.continue_on_error: + argv.append("--continue-on-error") + + job = await ctx.jobs.start( + plugin=ctx.id, + label=("Aufräumen" if request.apply else "Trockenübung") + f": {file.stem}", + argv=argv, cwd=ctx.data_dir, env=tool_env(), + ) + return job.info() + + @app.post("/api/run/vorbelegen") + async def run_prefill(request: PrefillRequest) -> dict[str, Any]: + if ctx.jobs.running(ctx.id): + raise HTTPException(409, "Es läuft bereits etwas.") + if not ctx.settings.status()["openai"]: + raise HTTPException(400, "OpenAI-Key fehlt – siehe Einstellungen.") + file = resolve_plan(request.plan) + argv = [sys.executable, str(tool), "vorbelegen", "--plan", str(file)] + if request.model: + argv += ["--model", request.model] + if request.alle_plurale: + argv.append("--alle-plurale") + job = await ctx.jobs.start( + plugin=ctx.id, label=f"KI-Vorauswahl: {file.stem}", + argv=argv, cwd=ctx.data_dir, env=tool_env(), + ) + return job.info() + + @app.post("/api/run/probe") + async def run_probe(request: ProbeRequest) -> dict[str, Any]: + if ctx.jobs.running(ctx.id): + raise HTTPException(409, "Es läuft bereits etwas.") + if not ctx.settings.status()["openai"]: + raise HTTPException(400, "OpenAI-Key fehlt – siehe Einstellungen.") + argv = [sys.executable, str(tool), "probe"] + if request.model: + argv += ["--model", request.model] + job = await ctx.jobs.start( + plugin=ctx.id, label="OpenAI-Verbindung testen", + argv=argv, cwd=ctx.data_dir, env=tool_env(), + ) + return job.info() + + def restore_argv(run: str, apply: bool, force: bool) -> list[str]: + argv = [sys.executable, str(tool), "zurueck", "--lauf", run] + if apply: + argv.append("--apply") + if force: + argv.append("--force") + return argv + + app.include_router(backup_router(ctx, "laeufe", restore_argv)) + app.include_router(job_router(ctx.jobs, ctx.id)) + return app diff --git a/plugins/tandoor-cleanup/plugin.json b/plugins/tandoor-cleanup/plugin.json new file mode 100644 index 0000000..a4f42cb --- /dev/null +++ b/plugins/tandoor-cleanup/plugin.json @@ -0,0 +1,19 @@ +{ + "id": "tandoor-cleanup", + "name": "Stammdaten aufräumen", + "summary": "Dubletten zusammenführen, Karteileichen finden", + "description": "Führt doppelte Zutaten, Einheiten und Schlagworte zusammen und findet Einträge, die in keinem Rezept mehr vorkommen. Rettet vorher die Nährwerte des Quelleintrags, die Tandoor beim Zusammenführen sonst verwirft.", + "icon": "🧹", + "category": "Tandoor", + "version": "1.0.0", + "entrypoint": "backend:create_app", + "order": 45, + "requires": ["tandoor"], + "features": [ + "Erkennt Zwiebel/Zwiebeln, lässt Rote Zwiebel in Ruhe", + "Rettet Nährwerte, die beim Zusammenführen sonst verloren gingen", + "Trockenübung, Einzelfreigabe, Sicherung je Schritt", + "Grenzfälle nur zur Ansicht, nie vorausgewählt" + ], + "docs": "TOOL-README.md" +} diff --git a/plugins/tandoor-cleanup/static/index.html b/plugins/tandoor-cleanup/static/index.html new file mode 100644 index 0000000..5af63bb --- /dev/null +++ b/plugins/tandoor-cleanup/static/index.html @@ -0,0 +1,624 @@ + + + + + +Stammdaten aufräumen + + + + + + + + +
+
+

Stammdaten aufräumen

+

Führt doppelte Zutaten, Einheiten und Schlagworte zusammen und findet + Karteileichen. Erst prüfen, dann in Ruhe durchsehen, dann ausführen.

+
+ +
+ +
+ Zusammenführen und Löschen sind in Tandoor nicht umkehrbar. + Tandoor hängt alle Rezeptverweise um und löscht den Quelleintrag; dessen + Nährwerte gehen dabei laut Tandoor-Code verloren. Dieses Werkzeug rettet sie + deshalb vorher ans Ziel und schreibt vor jedem Schritt eine Sicherung. + Vorgeschlagen wird nur, was praktisch sicher dasselbe ist — Grenzfälle + stehen unter „Ähnlich“ und werden nie vorausgewählt. +
+ +
+
+
+ +
+ Zutaten + Einheiten + Schlagworte +
+
+ +
+ + +

Für Einheiten und Schlagworte werden alle Rezepte gelesen — + anders als Zutaten führen sie keine Rezeptzahl mit.

+
Bereit.
+
+
+ +
+
+
+ + +
+ +
+
+ + + +
Noch nicht geprüft.
+ + + +
+
+ + + + diff --git a/plugins/tandoor-cleanup/tool/cleanup.py b/plugins/tandoor-cleanup/tool/cleanup.py new file mode 100644 index 0000000..153c909 --- /dev/null +++ b/plugins/tandoor-cleanup/tool/cleanup.py @@ -0,0 +1,1377 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Stammdaten aufräumen: Zutaten, Einheiten und Schlagworte. + + cleanup.py pruefen + Sucht Dubletten, ähnliche Namen und unbenutzte Einträge und schreibt + einen Plan. Verändert nichts. + + cleanup.py ausfuehren --plan [--apply] + Arbeitet den Plan ab. Ohne --apply nur eine Trockenübung. + +Warum das mit Vorsicht zu genießen ist +-------------------------------------- +Tandoors merge hängt alle Verweise der Quelle auf das Ziel um und löscht die +Quelle danach. Das ist **nicht umkehrbar**. Im Tandoor-Quelltext steht dazu: + + # TODO these checks could be improved to merge existing properties and + # conversion in a smart way. For now it will just loose them to prevent + # duplicates + if isinstance(source, Food): + source.properties.all().delete() + +Die Nährwerte der Quelle gehen also verloren. Deshalb rettet dieses Werkzeug +sie vorher: Werte, die nur die Quelle hat, werden zuerst ans Ziel geschrieben +(--ohne-rettung schaltet das ab). Und deshalb wird nur zusammengeführt, was +praktisch sicher dasselbe ist — alles Grenzwertige landet nur in einer Liste +zum Anschauen. + +Standalone: + python3 plugins/tandoor-cleanup/tool/cleanup.py pruefen +""" +from __future__ import annotations + +import argparse +import json +import os +import secrets +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +SUITE_ROOT = Path(__file__).resolve().parents[3] +if str(SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(SUITE_ROOT)) + +from core.tandoor import ( # noqa: E402 + TandoorClient, + TandoorError, + food_property_map, + merged_properties_payload, +) +from core.foodmatch import ( # noqa: E402 + duplicate_groups, + similar_pairs, + looks_like_plural, + guess_plural, +) +from core import backups # noqa: E402 +from core import ai # noqa: E402 + +KINDS = { + "food": {"endpoint": "food", "label": "Zutat", "plural": "Zutaten"}, + "unit": {"endpoint": "unit", "label": "Einheit", "plural": "Einheiten"}, + "keyword": {"endpoint": "keyword", "label": "Schlagwort", "plural": "Schlagworte"}, +} + + +def out(text: str = "") -> None: + print(text, flush=True) + + +def data_dir() -> Path: + configured = os.environ.get("DATA_DIR", "").strip() + base = Path(configured) if configured else SUITE_ROOT / "data" / "tandoor-cleanup" + base.mkdir(parents=True, exist_ok=True) + return base + + +def stamp() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def short(obj: dict[str, Any]) -> dict[str, Any]: + return { + "id": obj.get("id"), + "name": obj.get("name"), + "plural_name": obj.get("plural_name") or "", + "numrecipe": obj.get("numrecipe") or 0, + "properties": len(obj.get("properties") or []), + } + + +def reference_index(client: TandoorClient) -> dict[str, Any]: + """ + Wer zeigt worauf? Einmal alle Rezepte lesen und merken. + + Das muss VOR dem Zusammenführen passieren: Danach zeigen die Verweise auf + das Ziel und die alte Zuordnung ist nicht mehr rekonstruierbar. Genau diese + Aufzeichnung ist es, die ein Rückspielen später überhaupt möglich macht. + """ + out("Rezeptverweise werden aufgezeichnet …") + foods: dict[int, list[dict[str, Any]]] = {} + units: dict[int, list[dict[str, Any]]] = {} + keywords: dict[int, list[dict[str, Any]]] = {} + + overview = client.list_objects("recipe") + for index, entry in enumerate(overview, start=1): + rid = entry.get("id") + try: + recipe = client.get_json(f"api/recipe/{rid}/") + except TandoorError: + continue + + kw_ids = [k["id"] for k in recipe.get("keywords") or [] + if isinstance(k, dict) and isinstance(k.get("id"), int)] + for kid in kw_ids: + keywords.setdefault(kid, []).append({ + "recipe_id": rid, + "recipe_name": recipe.get("name"), + # Die vollständige Liste, nicht nur der eine Treffer: Beim + # Zurückspielen wird sie eins zu eins wiederhergestellt. + "keywords_before": kw_ids, + }) + + for step in recipe.get("steps") or []: + for ing in step.get("ingredients") or []: + iid = ing.get("id") + if not isinstance(iid, int): + continue + food = (ing.get("food") or {}).get("id") + unit = (ing.get("unit") or {}).get("id") + if isinstance(food, int): + foods.setdefault(food, []).append({ + "ingredient_id": iid, "recipe_id": rid, + "recipe_name": recipe.get("name"), "food_id": food, + }) + if isinstance(unit, int): + units.setdefault(unit, []).append({ + "ingredient_id": iid, "recipe_id": rid, + "recipe_name": recipe.get("name"), "unit_id": unit, + }) + if index % 25 == 0 or index == len(overview): + out(f" {index}/{len(overview)}") + + return {"food": foods, "unit": units, "keyword": keywords} + + +# ------------------------------------------------------------------ Prüfen + +def scan(client: TandoorClient, args: argparse.Namespace) -> dict[str, Any]: + kinds = [k.strip() for k in args.arten.split(",") if k.strip()] if args.arten else list(KINDS) + unknown = [k for k in kinds if k not in KINDS] + if unknown: + raise SystemExit(f"Unbekannte Art: {unknown}. Erlaubt: {', '.join(KINDS)}") + + result: dict[str, Any] = { + "created_at": datetime.now(timezone.utc).isoformat(), + "kinds": {}, + "actions": [], + } + + # Rezepte einmal lesen, um benutzte Zutaten/Einheiten/Schlagworte zu + # bestimmen. Wichtig: Tandoors Food-LISTE liefert „numrecipe“ oft nicht mit + # (dann stünde es überall auf 0 und ALLE Zutaten wären scheinbar unbenutzt). + # Deshalb bestimmen wir die Benutzung verlässlich aus den Rezepten selbst. + used_foods: set[int] = set() + used_units: set[int] = set() + used_keywords: set[int] = set() + if kinds: + out("Rezepte werden gelesen, um Benutzung zu bestimmen …") + overview = client.list_objects("recipe") + for index, entry in enumerate(overview, start=1): + try: + recipe = client.get_json(f"api/recipe/{entry['id']}/") + except TandoorError: + continue + for kw in recipe.get("keywords") or []: + if isinstance(kw, dict) and isinstance(kw.get("id"), int): + used_keywords.add(kw["id"]) + for step in recipe.get("steps") or []: + for ing in step.get("ingredients") or []: + food = (ing.get("food") or {}).get("id") + if isinstance(food, int): + used_foods.add(food) + unit = ing.get("unit") + if isinstance(unit, dict) and isinstance(unit.get("id"), int): + used_units.add(unit["id"]) + if index % 25 == 0 or index == len(overview): + out(f" {index}/{len(overview)}") + + counter = {"n": 0} + + def new_aid() -> str: + counter["n"] += 1 + return f"a{counter['n']:04d}" + + for kind in kinds: + endpoint = KINDS[kind]["endpoint"] + out(f"{KINDS[kind]['plural']} werden geprüft …") + objects = client.list_objects(endpoint) + by_id = {o["id"]: o for o in objects} + out(f" {len(objects)} Einträge") + + groups = duplicate_groups(objects) + pairs = similar_pairs(objects) + + def plural_for_merge(source_obj: dict, target_obj: dict) -> str: + """ + Beim Zusammenführen: Wenn die Quelle wie ein Plural des Ziels aussieht + und das Ziel noch keinen Plural gepflegt hat, diesen vorschlagen. + """ + if kind != "food": + return "" + if (target_obj.get("plural_name") or "").strip(): + return "" + src = (source_obj.get("name") or "").strip() + tgt = (target_obj.get("name") or "").strip() + return src if looks_like_plural(tgt, src) else "" + + # Unbenutzt = kommt in keinem Rezept vor. Für Zutaten ist der + # Rezept-Scan maßgeblich; numrecipe zählt zusätzlich als „benutzt“, + # falls es doch mitgeliefert wird — so wird nie etwas fälschlich als + # unbenutzt (und damit löschbar) markiert. + if kind == "food": + unused = [short(o) for o in objects + if o.get("id") not in used_foods and not (o.get("numrecipe") or 0)] + elif kind == "unit": + unused = [short(o) for o in objects if o.get("id") not in used_units] + else: + unused = [short(o) for o in objects if o.get("id") not in used_keywords] + unused.sort(key=lambda o: (o["name"] or "").casefold()) + + # ---------------------------------------------------- sichere Dubletten + plan_groups = [] + for group in groups: + target = group["target"] + target_obj = by_id[target["id"]] + sources = [] + for source in group["sources"]: + source_obj = by_id[source["id"]] + verlust = [] + if kind == "food": + ziel_werte = food_property_map(target_obj) + quell_werte = food_property_map(source_obj) + verlust = [ + tid for tid, wert in quell_werte.items() + if wert is not None and ziel_werte.get(tid) is None + ] + pv = plural_for_merge(source_obj, target_obj) + aid = new_aid() + sources.append({ + **short(source), + "aid": aid, + "rescue_properties": verlust, + "plural_suggestion": pv, + }) + result["actions"].append({ + "aid": aid, + "kind": kind, + "endpoint": endpoint, + "action": "merge", + "origin": "duplicate", + "source_id": source["id"], + "source_name": source.get("name"), + "target_id": target["id"], + "target_name": target.get("name"), + "rescue_properties": verlust, + "reason": group["reason"], + "score": group["score"], + # Für Food: Plural am Ziel ergänzen, wenn sinnvoll — und + # dann auch gleich vorausgewählt, denn das ist genau der + # Fall, den man haben will. + "set_plural": bool(pv), + "plural_value": pv, + "accept": True, + }) + plan_groups.append({ + "target": short(target), + "sources": sources, + "score": group["score"], + "reason": group["reason"], + "recipes_affected": group["recipes_affected"], + }) + + # ------------------------------------------------------- ähnliche Paare + # Anders als bisher: jetzt bedienbar. Häkchen und Richtung wählt der + # Nutzer in der Oberfläche; hier wird nur der Kandidat vorbereitet, + # ohne Vorauswahl und ohne Richtung. + similar = [] + for pair in pairs: + left, right = short(pair["left"]), short(pair["right"]) + aid = new_aid() + pv_lr = plural_for_merge(by_id[left["id"]], by_id[right["id"]]) # links->rechts + pv_rl = plural_for_merge(by_id[right["id"]], by_id[left["id"]]) # rechts->links + similar.append({ + "aid": aid, + "left": left, + "right": right, + "score": pair["score"], + "reason": pair["reason"], + "plural_left_to_right": pv_lr, + "plural_right_to_left": pv_rl, + }) + result["actions"].append({ + "aid": aid, + "kind": kind, + "endpoint": endpoint, + "action": "merge", + "origin": "similar", + "left_id": left["id"], + "left_name": left["name"], + "right_id": right["id"], + "right_name": right["name"], + # Richtung offen — die Oberfläche setzt source_id/target_id auf + # genau eines der beiden Paar-Mitglieder. + "source_id": None, + "source_name": None, + "target_id": None, + "target_name": None, + "rescue_properties": [], + "reason": pair["reason"], + "score": pair["score"], + "set_plural": False, + "plural_value": "", + # Pluralvorschlag je Richtung, damit die Oberfläche beim + # Umschalten sofort das Richtige anbietet. + "plural_left_to_right": pv_lr, + "plural_right_to_left": pv_rl, + "accept": False, + }) + + # ---------------------------------------------------- Plurale pflegen + # Vollständige Sicht auf die Plurale — für Zutaten UND Einheiten. Jeder + # Eintrag ist einzeln korrigier- und eintragbar. Nicht destruktiv, voll + # rückspielbar, nichts vorausgewählt. Schlagworte haben keinen Plural. + plurals = [] + if kind in ("food", "unit"): + for o in objects: + aktuell = (o.get("plural_name") or "").strip() + aid = new_aid() + vorschlag = guess_plural(o.get("name") or "") if not aktuell else "" + # Benutzung aus dem Rezept-Scan (numrecipe ist unzuverlässig). + if kind == "food": + benutzt = 1 if (o.get("id") in used_foods or (o.get("numrecipe") or 0)) else 0 + else: + benutzt = 1 if o.get("id") in used_units else 0 + plurals.append({ + "aid": aid, + "id": o["id"], + "name": o.get("name"), + "plural": aktuell, + "used": benutzt, + "suggestion": vorschlag, + }) + result["actions"].append({ + "aid": aid, + "kind": kind, + "endpoint": endpoint, + "action": "set_plural", + "source_id": o["id"], + "source_name": o.get("name"), + "target_id": None, + "target_name": None, + # Vorbelegung = aktueller Plural, damit Bearbeiten den Stand zeigt. + "plural_value": aktuell, + "plural_before_scan": aktuell, + "reason": "Plural pflegen", + "score": 0, + "accept": False, + }) + # Fehlende Plurale zuerst, dann die meistbenutzten, dann alphabetisch. + plurals.sort(key=lambda m: (bool(m["plural"]), -(m["used"]), + (m["name"] or "").casefold())) + + # ------------------------------------------------------------ Löschen + for item in unused: + result["actions"].append({ + "aid": new_aid(), + "kind": kind, + "endpoint": endpoint, + "action": "delete", + "source_id": item["id"], + "source_name": item["name"], + "target_id": None, + "target_name": None, + "rescue_properties": [], + "reason": "in keinem Rezept verwendet", + "score": 0, + # Löschen wird bewusst NICHT vorausgewählt. + "accept": False, + }) + + result["kinds"][kind] = { + "label": KINDS[kind]["label"], + "plural": KINDS[kind]["plural"], + "total": len(objects), + "duplicate_groups": plan_groups, + "similar": similar, + "unused": unused, + "plurals": plurals, + } + + # KI-Vorfilter: definitiv verschiedene Paare aussortieren, bevor die Liste + # überhaupt erscheint. Nur wenn gewünscht und ein Schlüssel da ist. + if getattr(args, "ki_filter", False): + if not os.environ.get("OPENAI_API_KEY"): + out("KI-Vorauswahl übersprungen — OPENAI_API_KEY fehlt.") + else: + model = getattr(args, "model", None) or os.environ.get("OPENAI_MODEL", "gpt-5.5") + try: + z = _ai_classify_similars(result, model) + result["ai_filtered"] = True + out(f"KI-Vorauswahl: {z['geprueft']} Paare — {z['gleich']} gleich " + f"(angehakt), {z['unklar']} unklar, {z['verschieden']} verschieden " + f"(ausgeblendet).") + except Exception as exc: # noqa: BLE001 + out(f"KI-Vorauswahl fehlgeschlagen (Paare bleiben unverändert): {exc}") + + return result + + +def print_scan(plan: dict[str, Any]) -> None: + out() + out("─" * 64) + for kind, data in plan["kinds"].items(): + merges = sum(len(g["sources"]) for g in data["duplicate_groups"]) + out(f" {data['plural']:<14} {data['total']:>4} Einträge · " + f"{merges} zusammenführbar · {len(data['similar'])} zur Ansicht · " + f"{len(data['unused'])} unbenutzt") + out("─" * 64) + + for kind, data in plan["kinds"].items(): + if data["duplicate_groups"]: + out() + out(f" {data['plural']} — sicher dasselbe:") + for group in data["duplicate_groups"]: + t = group["target"] + out(f" Ziel: {t['name']} [{t['id']}] " + f"({t['numrecipe']} Rezepte, {t['properties']} Eigenschaften)") + for s in group["sources"]: + rettung = (f" ⚠ {len(s['rescue_properties'])} Werte werden vorher gerettet" + if s["rescue_properties"] else "") + out(f" ← {s['name']} [{s['id']}] " + f"({s['numrecipe']} Rezepte){rettung}") + out(f" {group['reason']} · {group['score']}%") + + if data["similar"]: + out() + out(f" {data['plural']} — ähnlich, bitte selbst entscheiden " + f"(nicht vorausgewählt):") + for pair in data["similar"][:15]: + out(f" {pair['left']['name']} [{pair['left']['id']}] ~ " + f"{pair['right']['name']} [{pair['right']['id']}] " + f"{pair['score']}% · {pair['reason']}") + + if data["unused"]: + out() + out(f" {data['plural']} — unbenutzt ({len(data['unused'])}, " + f"Löschen ist nicht vorausgewählt):") + for item in data["unused"][:15]: + out(f" {item['name']} [{item['id']}]") + if len(data["unused"]) > 15: + out(f" … und {len(data['unused']) - 15} weitere") + + +# -------------------------------------------------------------- Ausführen + +def rescue_properties( + client: TandoorClient, + source_id: int, + target_id: int, + dry: bool, +) -> tuple[str, list[int]]: + """ + Werte, die nur die Quelle hat, vor dem Zusammenführen ans Ziel retten. + + Ermittelt selbst, welche Eigenschaften das Ziel noch fehlen und die Quelle + hat — nötig, weil bei „ähnlichen“ Paaren die Richtung erst in der Oberfläche + gewählt wird. Gibt (Notiz, gerettete Eigenschafts-IDs) zurück. + """ + source = client.get_json(f"api/food/{source_id}/") + target = client.get_json(f"api/food/{target_id}/") + quelle = food_property_map(source) + ziel = food_property_map(target) + + updates = { + tid: wert for tid, wert in quelle.items() + if wert is not None and ziel.get(tid) is None + } + if not updates: + return "", [] + if dry: + return f"würde {len(updates)} Werte ans Ziel retten", list(updates) + + body: dict[str, Any] = {"properties": merged_properties_payload(target, updates)} + if not target.get("properties_food_amount") and source.get("properties_food_amount"): + body["properties_food_amount"] = source["properties_food_amount"] + unit = source.get("properties_food_unit") + if isinstance(unit, dict) and unit.get("id"): + body["properties_food_unit"] = {"id": unit["id"], "name": unit.get("name")} + client.patch_json(f"api/food/{target_id}/", body) + return f"{len(updates)} Werte gerettet", list(updates) + + +def _set_plural(client: TandoorClient, endpoint: str, obj: dict[str, Any], plural: str) -> str: + """ + Setzt ``plural_name`` so robust wie möglich und gibt die erfolgreiche + Methode zurück. + + Zutaten akzeptiert Tandoor über einen schlichten Teil-PATCH. Einheiten + lassen sich damit in manchen Tandoor-Versionen NICHT ändern — der Server + antwortet mit HTTP 500. Deshalb wird dort zuerst ein vollständiger PUT + versucht (anderer Code-Pfad, alle vorhandenen Felder bleiben erhalten, damit + z. B. eine hinterlegte Basiseinheit nicht verloren geht), danach PATCH mit + Name und zuletzt der schlichte PATCH. Scheitert alles, wirft es den letzten + Fehler. + """ + url = f"api/{endpoint}/{obj['id']}/" + if endpoint == "food": + client.patch_json(url, {"plural_name": plural}) + return "PATCH" + + voll = dict(obj) + voll["plural_name"] = plural + versuche = ( + ("PUT", lambda: client.put_json(url, voll)), + ("PATCH name+plural", + lambda: client.patch_json(url, {"name": obj.get("name"), "plural_name": plural})), + ("PATCH plural", lambda: client.patch_json(url, {"plural_name": plural})), + ) + letzter: Exception | None = None + for _label, call in versuche: + try: + call() + return _label + except TandoorError as exc: + letzter = exc + raise letzter # type: ignore[misc] + + +def execute(client: TandoorClient, args: argparse.Namespace) -> int: + file = Path(args.plan).expanduser().resolve() + if not file.is_file(): + raise SystemExit(f"Plandatei nicht gefunden: {file}") + plan = json.loads(file.read_text(encoding="utf-8")) + + actions = [a for a in plan.get("actions", []) if a.get("accept")] + merges = [a for a in actions if a["action"] == "merge"] + set_plurals = [a for a in actions if a["action"] == "set_plural"] + deletes = [a for a in actions if a["action"] == "delete"] + if not actions: + out("Im Plan ist nichts angehakt.") + return 0 + + # Angehakte „ähnliche“ Paare ohne gewählte Richtung sind unvollständig. + ohne_richtung = [a for a in merges + if not (isinstance(a.get("source_id"), int) + and isinstance(a.get("target_id"), int) + and a["source_id"] != a["target_id"])] + if ohne_richtung: + for a in ohne_richtung: + paar = f"„{a.get('left_name')}“ ~ „{a.get('right_name')}“" + out(f"Übersprungen (keine Richtung gewählt): {paar}") + merges = [a for a in merges if a not in ohne_richtung] + + if not merges and not set_plurals and not deletes: + out("Nichts Ausführbares angehakt.") + return 0 + + out(f"Modus: {'AUSFÜHREN' if args.apply else 'TROCKENÜBUNG'}") + out(f"Tandoor: {client.base_url}") + out(f"{len(merges)} Zusammenführungen · {len(set_plurals)} Plural-Ergänzungen " + f"· {len(deletes)} Löschungen") + if args.apply: + out() + out("Zusammenführen und Löschen sind in Tandoor NICHT umkehrbar.") + out("Dieses Werkzeug zeichnet vorher auf, was nötig ist, um den") + out("Zustand später wieder herzustellen — siehe Reiter „Sicherungen“.") + out() + + index: dict[str, Any] = {"food": {}, "unit": {}, "keyword": {}} + if merges: + index = reference_index(client) + out() + + run = data_dir() / "laeufe" / stamp() + run.mkdir(parents=True, exist_ok=True) + (run / "plan.json").write_text( + json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8") + + manifest: dict[str, Any] = { + "created_at": datetime.now(timezone.utc).isoformat(), + "plugin": "tandoor-cleanup", + "label": f"Aufräumen ({file.stem})", + "tandoor": client.base_url, + "mode": "apply" if args.apply else "dry", + "plan": file.name, + "steps": [], + } + + done, failed = 0, 0 + + # Erst zusammenführen, dann löschen: Eine Zusammenführung kann einen + # Eintrag unbenutzt machen, andersherum wäre die Quelle schon weg. + for nummer, action in enumerate(merges, start=1): + kind = action["kind"] + label = KINDS[kind]["label"] + verweise = index.get(kind, {}).get(action["source_id"], []) + + try: + quelle = client.get_json(f"api/{action['endpoint']}/{action['source_id']}/") + ziel = client.get_json(f"api/{action['endpoint']}/{action['target_id']}/") + except TandoorError as exc: + out(f"[{nummer}/{len(merges)}] {label} " + f"[{action['source_id']}] → [{action['target_id']}]: nicht lesbar — {exc}") + failed += 1 + if not args.continue_on_error: + return 1 + continue + + source_name = action.get("source_name") or quelle.get("name") + target_name = action.get("target_name") or ziel.get("name") + prefix = (f"[{nummer}/{len(merges)}] {label}: " + f"„{source_name}“ [{action['source_id']}] → " + f"„{target_name}“ [{action['target_id']}]") + + # Plural am Ziel ergänzen? Nur Food, nur wenn Ziel noch keinen hat. + will_plural = (kind == "food" and action.get("set_plural") + and (action.get("plural_value") or "").strip() + and not (ziel.get("plural_name") or "").strip()) + plural_value = (action.get("plural_value") or "").strip() + + step: dict[str, Any] = { + "n": nummer, + "action": "merge", + "kind": kind, + "endpoint": action["endpoint"], + "origin": action.get("origin", "duplicate"), + "source": quelle, + "source_id": action["source_id"], + "source_name": action["source_name"] or quelle.get("name"), + "target_id": action["target_id"], + "target_name": action["target_name"] or ziel.get("name"), + "target_before": ziel, + "references": verweise, + "rescued": [], + "plural_set": plural_value if will_plural else "", + "target_plural_before": ziel.get("plural_name") or "", + "restore_level": "teilweise", + "status": "geplant", + } + + try: + if args.apply: + for rolle, obj in (("quelle", quelle), ("ziel", ziel)): + (run / f"{kind}-{rolle}-{obj['id']:05d}.json").write_text( + json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8") + + note = "" + if kind == "food" and not args.ohne_rettung: + note, gerettet = rescue_properties( + client, action["source_id"], action["target_id"], dry=not args.apply) + step["rescued"] = gerettet + + plural_note = f"Plural „{plural_value}“ ergänzt" if will_plural else "" + + if not args.apply: + out(f"{prefix}: würde zusammenführen" + + (f" · {note}" if note else "") + + (f" · {len(verweise)} Verweise werden umgehängt" if verweise else "") + + (f" · würde {plural_note}" if will_plural else "")) + done += 1 + continue + + step["status"] = "angefangen" + manifest["steps"].append(step) + backups.write_manifest(run, manifest) + + client.merge(action["endpoint"], action["source_id"], action["target_id"]) + client.forget(action["endpoint"]) + + if will_plural: + client.patch_json(f"api/food/{action['target_id']}/", + {"plural_name": plural_value}) + + step["status"] = "done" + backups.write_manifest(run, manifest) + out(f"{prefix}: ✓ zusammengeführt" + + (f" · {note}" if note else "") + + (f" · {len(verweise)} Verweise umgehängt" if verweise else "") + + (f" · {plural_note}" if will_plural else "")) + done += 1 + except TandoorError as exc: + out(f"{prefix}: FEHLER — {exc}") + step["status"] = "fehler" + step["error"] = str(exc) + if args.apply: + backups.write_manifest(run, manifest) + failed += 1 + if not args.continue_on_error: + out("Abbruch. Bereits ausgeführte Schritte bleiben bestehen.") + return 1 + + # ---------------------------------------------------- Plurale pflegen + plural_kaputt: set[str] = set() # Endpunkte, die Tandoor systematisch ablehnt + for nummer, action in enumerate(set_plurals, start=1): + endpoint = action.get("endpoint", "food") + kind = action.get("kind", "food") + obj_id = action["source_id"] + wert = (action.get("plural_value") or "").strip() + label = KINDS.get(kind, {}).get("label", "Eintrag") + prefix = f"[{nummer}/{len(set_plurals)}] Plural {label}: „{action['source_name']}“ [{obj_id}]" + + if endpoint in plural_kaputt: + out(f"{prefix}: übersprungen — {label}-Plurale werden von Tandoor " + f"abgelehnt (siehe erste Meldung; bitte in Tandoor selbst setzen)") + continue + + try: + obj = client.get_json(f"api/{endpoint}/{obj_id}/") + except TandoorError: + # Eintrag existiert nicht mehr (z. B. vorher zusammengeführt) — + # das ist kein Fehler, nur nichts mehr zu tun. + out(f"{prefix}: übersprungen — Eintrag nicht mehr vorhanden") + continue + + aktuell = (obj.get("plural_name") or "").strip() + if wert == aktuell: + out(f"{prefix}: unverändert („{aktuell}“) — übersprungen") + continue + + hinweis = f"„{aktuell}“ → „{wert}“" if aktuell else f"„{wert}“" + if not args.apply: + out(f"{prefix}: würde Plural setzen ({hinweis})") + done += 1 + continue + + try: + methode = _set_plural(client, endpoint, obj, wert) + except TandoorError as exc: + # Einheiten/Schlagworte: lehnt Tandoor den Schreibzugriff komplett + # ab, sind es fast sicher alle — nicht weiter hämmern. + if endpoint != "food": + plural_kaputt.add(endpoint) + out(f"{prefix}: FEHLER — Tandoor akzeptiert {label}-Plurale nicht " + f"(alle Schreibwege scheiterten). Weitere {label}-Plurale " + f"werden übersprungen; bitte direkt in Tandoor setzen.") + out(f" Letzte Serverantwort: {str(exc)[:200]}") + else: + out(f"{prefix}: FEHLER — {exc}") + failed += 1 + if not args.continue_on_error: + return 1 + continue + + # Erst nach erfolgreichem Schreiben sichern. + (run / f"{kind}-plural-{obj_id:05d}.json").write_text( + json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8") + manifest["steps"].append({ + "n": len(merges) + nummer, + "action": "set_plural", + "kind": kind, + "endpoint": endpoint, + "source_id": obj_id, + "source_name": action["source_name"], + "plural_before": aktuell, + "plural_after": wert, + "restore_level": "voll", + "status": "done", + }) + backups.write_manifest(run, manifest) + out(f"{prefix}: ✓ Plural gesetzt ({hinweis})" + + (f" [{methode}]" if methode not in ("PATCH", "PUT") else "")) + done += 1 + + for nummer, action in enumerate(deletes, start=1): + kind = action["kind"] + label = KINDS[kind]["label"] + prefix = (f"[{nummer}/{len(deletes)}] {label} löschen: " + f"„{action['source_name']}“ [{action['source_id']}]") + try: + obj = client.get_json(f"api/{action['endpoint']}/{action['source_id']}/") + step = { + "n": len(merges) + len(set_plurals) + nummer, + "action": "delete", + "kind": kind, + "endpoint": action["endpoint"], + "source": obj, + "source_id": action["source_id"], + "source_name": action["source_name"], + "references": [], + "restore_level": "neue_id", + "status": "geplant", + } + if not args.apply: + out(f"{prefix}: würde löschen") + done += 1 + continue + + (run / f"geloescht-{kind}-{obj['id']:05d}.json").write_text( + json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8") + step["status"] = "angefangen" + manifest["steps"].append(step) + backups.write_manifest(run, manifest) + + client.delete(f"api/{action['endpoint']}/{action['source_id']}/") + step["status"] = "done" + backups.write_manifest(run, manifest) + out(f"{prefix}: ✓ gelöscht") + done += 1 + except TandoorError as exc: + out(f"{prefix}: FEHLER — {exc}") + failed += 1 + if not args.continue_on_error: + return 1 + + if args.apply: + backups.write_manifest(run, manifest) + + out() + out("─" * 64) + verb = "ausgeführt" if args.apply else "würden ausgeführt" + out(f" {done} Schritte {verb} · {failed} Fehler") + if args.apply: + out(f" Sicherungen: {run}") + out(f" Zurückspielen: Reiter „Sicherungen“ oder") + out(f" cleanup.py zurueck --lauf {run.name} --apply") + else: + out(" Nichts verändert. Mit --apply wird es ernst.") + out("─" * 64) + return 1 if failed else 0 + + +# ---------------------------------------------------------- Zurückspielen + +def recreate( + client: TandoorClient, + endpoint: str, + payload: dict[str, Any], + forbidden_id: int | None, +) -> tuple[dict[str, Any], str]: + """ + Einen gelöschten Eintrag wieder anlegen. + + Tücke: Tandoor legt beim POST nicht immer etwas Neues an. Der Quelltext + macht ein get_or_create über den Namen — + + Food/Unit filter(Q(name__iexact=n) | Q(plural_name__iexact=n)) + Keyword get_or_create(name=n) (case-sensitiv) + + — und gibt sonst den vorhandenen Eintrag zurück. Nach einem + „Tomaten → Tomate“ ist „Tomaten“ als *Plural* von „Tomate“ belegt: Ein POST + „Tomaten“ liefert dann wieder „Tomate“, und die Wiederherstellung wäre + stillschweigend wirkungslos. + + Deshalb: erst anlegen, prüfen was zurückkam, und nur wenn nötig den Umweg + über einen freien Zwischennamen gehen. Das Umbenennen per PATCH prüft den + Namen nicht — und die Datenbank stört sich nicht daran, weil ihre + Eindeutigkeit case-sensitiv ist (Food hat gar keine). + """ + gewuenscht = (payload.get("name") or "").strip() + neu = client.post_json(f"api/{endpoint}/", payload) + client.forget(endpoint) + + passt = (neu.get("id") != forbidden_id + and (neu.get("name") or "").strip() == gewuenscht) + if passt: + return neu, "angelegt" + + # Tandoor hat einen vorhandenen Eintrag zurückgegeben. Umweg gehen. + zwischen = dict(payload) + zwischen["name"] = f"{gewuenscht}-wiederherstellung-{secrets.token_hex(3)}" + neu = client.post_json(f"api/{endpoint}/", zwischen) + client.forget(endpoint) + if neu.get("id") == forbidden_id: + raise TandoorError( + "Tandoor gibt beim Anlegen den Zieleintrag zurück — " + "der Name lässt sich nicht wieder belegen." + ) + try: + neu = client.patch_json(f"api/{endpoint}/{neu['id']}/", { + "name": gewuenscht, + "plural_name": payload.get("plural_name") or "", + }) + client.forget(endpoint) + except TandoorError as exc: + # Keine Leiche hinterlassen. + try: + client.delete(f"api/{endpoint}/{neu['id']}/") + except TandoorError: + pass + raise TandoorError(f"Umbenennen nach dem Anlegen schlug fehl: {exc}") from exc + + if (neu.get("name") or "").strip() != gewuenscht: + raise TandoorError( + f"Eintrag heißt nach dem Umbenennen „{neu.get('name')}“ " + f"statt „{gewuenscht}“." + ) + return neu, "angelegt (über Zwischennamen, da der Name belegt war)" + + +def restore(client: TandoorClient, args: argparse.Namespace) -> int: + """ + Einen Aufräum-Lauf rückgängig machen. + + Was das kann und was nicht: + + Gelöschte Einträge werden neu angelegt. Sie bekommen eine neue ID. Da nur + unbenutzte Einträge gelöscht werden, zeigte ohnehin nichts auf sie. + + Zusammengeführte Einträge werden neu angelegt und die aufgezeichneten + Rezeptverweise wieder auf sie zurückgehängt. Auch hier: neue ID. Was + nicht aufgezeichnet wurde — Einkaufslisten, Einheiten-Umrechnungen, + Automatisierungen —, bleibt beim Ziel. Deshalb „teilweise“. + """ + runs_dir = data_dir() / "laeufe" + try: + run = backups.resolve_run(runs_dir, args.lauf) + except FileNotFoundError as exc: + out(str(exc)) + return 2 + manifest = backups.read_manifest(run) or {} + steps = [s for s in manifest.get("steps", []) if s.get("status") == "done"] + if not steps: + out("In diesem Lauf wurde nichts ausgeführt — nichts zurückzuspielen.") + return 0 + + out(f"Modus: {'ZURÜCKSPIELEN' if args.apply else 'TROCKENÜBUNG'}") + out(f"Lauf: {run.name} ({manifest.get('label')})") + out(f" vom {manifest.get('created_at')}") + out(f"Tandoor: {client.base_url}") + if manifest.get("tandoor") and manifest["tandoor"] != client.base_url: + out() + out(f"ACHTUNG: Der Lauf ging gegen {manifest['tandoor']},") + out(f" jetzt eingestellt ist {client.base_url}.") + if not args.force: + out(" Abbruch. Mit --force trotzdem.") + return 2 + verlauf = backups.restore_history(run) + if verlauf and not args.force: + out() + out(f"Dieser Lauf wurde bereits am {verlauf[-1]['at']} zurückgespielt.") + out("Mit --force noch einmal.") + return 2 + + out(f"{len(steps)} Schritte") + out() + out("Wichtig: Wiederhergestellte Einträge bekommen eine NEUE ID. Die alte") + out("ist in Tandoor vergeben und weg. Rezepte zeigen danach wieder auf den") + out("richtigen Eintrag, nur eben unter neuer Nummer.") + out() + + zurueck, fehler = 0, 0 + + # Rückwärts: der letzte Eingriff wird zuerst rückgängig gemacht. + for step in reversed(steps): + # Reine Plural-Ergänzung: nur den alten Plural zurückschreiben. + if step.get("action") == "set_plural": + endpoint = step.get("endpoint", "food") + fid = step["source_id"] + alt = step.get("plural_before", "") + prefix = f"[{step['n']}] Plural „{step['source_name']}“ [{fid}]" + if not args.apply: + out(f"{prefix}: würde Plural auf „{alt}“ zurücksetzen") + zurueck += 1 + continue + try: + obj = client.get_json(f"api/{endpoint}/{fid}/") + _set_plural(client, endpoint, obj, alt) + out(f"{prefix}: ✓ Plural zurückgesetzt") + zurueck += 1 + except TandoorError as exc: + out(f"{prefix}: FEHLER — {exc}") + fehler += 1 + continue + + kind = step["kind"] + label = KINDS[kind]["label"] + endpoint = step["endpoint"] + quelle = step["source"] + prefix = f"[{step['n']}] {label} „{step['source_name']}“" + + # Wurde beim Zusammenführen ein Plural am Ziel ergänzt, muss dieser + # ZUERST zurück — sonst gäbe Tandoor beim Anlegen der Quelle (deren Name + # jetzt der Plural des Ziels ist) den Zieleintrag zurück. + if args.apply and step.get("plural_set") and isinstance(step.get("target_id"), int): + try: + client.patch_json(f"api/food/{step['target_id']}/", + {"plural_name": step.get("target_plural_before", "")}) + client.forget(endpoint) + except TandoorError: + pass + + # 1. Eintrag wieder anlegen + payload: dict[str, Any] = { + "name": quelle.get("name"), + "plural_name": quelle.get("plural_name") or "", + } + if quelle.get("description"): + payload["description"] = quelle["description"] + if kind == "food": + props = [] + for prop in quelle.get("properties") or []: + tid = (prop.get("property_type") or {}).get("id") + if isinstance(tid, int): + props.append({"property_amount": prop.get("property_amount"), + "property_type": {"id": tid}}) + if props: + payload["properties"] = props + if quelle.get("properties_food_amount") is not None: + payload["properties_food_amount"] = quelle["properties_food_amount"] + pfu = quelle.get("properties_food_unit") + if isinstance(pfu, dict) and pfu.get("id"): + payload["properties_food_unit"] = {"id": pfu["id"], "name": pfu.get("name")} + + verweise = step.get("references") or [] + if not args.apply: + teile = [f"würde {label.lower()} „{quelle.get('name')}“ neu anlegen"] + if kind == "food" and payload.get("properties"): + teile.append(f"{len(payload['properties'])} Eigenschaften zurück") + if verweise: + teile.append(f"{len(verweise)} Verweise zurückhängen") + out(f"{prefix}: " + " · ".join(teile)) + zurueck += 1 + continue + + try: + neu, wie = recreate(client, endpoint, payload, step.get("target_id")) + except TandoorError as exc: + out(f"{prefix}: konnte nicht angelegt werden — {exc}") + fehler += 1 + continue + + neue_id = neu.get("id") + if neue_id == step["source_id"]: + hinweis = f"wieder unter ID {neue_id}" + else: + hinweis = f"neu unter ID {neue_id} (vorher {step['source_id']})" + if "Zwischennamen" in wie: + hinweis += ", Name war belegt" + + # 2. Verweise zurückhängen + umgehaengt, verfehlt = 0, 0 + for ref in verweise: + try: + if kind in ("food", "unit"): + iid = ref["ingredient_id"] + aktuell = None + # Schutz: Nur zurückhängen, was tatsächlich noch auf dem + # Ziel steht. Wer die Zutat seither von Hand geändert hat, + # soll das nicht überschrieben bekommen. + recipe = client.get_json(f"api/recipe/{ref['recipe_id']}/") + for st in recipe.get("steps") or []: + for ing in st.get("ingredients") or []: + if ing.get("id") == iid: + aktuell = ing + if aktuell is None: + verfehlt += 1 + continue + feld = "food" if kind == "food" else "unit" + zeigt_auf = (aktuell.get(feld) or {}).get("id") + if zeigt_auf != step["target_id"] and not args.force: + verfehlt += 1 + continue + client.patch_json(f"api/ingredient/{iid}/", + {feld: {"id": neue_id, "name": neu.get("name")}}) + umgehaengt += 1 + else: # keyword + vorher = ref.get("keywords_before") or [] + liste = [{"id": neue_id if k == step["source_id"] else k} + for k in vorher] + client.patch_json(f"api/recipe/{ref['recipe_id']}/", + {"keywords": liste}) + umgehaengt += 1 + except TandoorError as exc: + out(f" Verweis {ref}: {exc}") + verfehlt += 1 + + teile = [f"✓ {hinweis}"] + if umgehaengt: + teile.append(f"{umgehaengt} Verweise zurückgehängt") + if verfehlt: + teile.append(f"{verfehlt} übersprungen (zwischenzeitlich geändert)") + out(f"{prefix}: " + " · ".join(teile)) + zurueck += 1 + + out() + out("─" * 64) + verb = "zurückgespielt" if args.apply else "würden zurückgespielt" + out(f" {zurueck} Schritte {verb} · {fehler} Fehler") + if args.apply: + backups.mark_restored(run, {"restored": zurueck, "errors": fehler}) + out(" Der Lauf ist als zurückgespielt vermerkt.") + out() + out(" Was NICHT zurückkommt: Einkaufslisten-Einträge, Einheiten-") + out(" Umrechnungen und Automatisierungen, die auf den alten Eintrag") + out(" zeigten. Die hat Tandoor beim Zusammenführen aufs Ziel gehängt") + out(" und dort bleiben sie.") + else: + out(" Nichts verändert. Mit --apply wird zurückgespielt.") + out("─" * 64) + return 1 if fehler else 0 + + +# ----------------------------------------------------------------- Aufruf + +# ======================================================== KI-Vorauswahl (ChatGPT) + +PLURAL_BATCH = 40 + +_KIND_PLURAL = {"food": "Zutaten", "unit": "Einheiten", "keyword": "Schlagworte"} +_KIND_SINGULAR = {"food": "Zutat", "unit": "Maßeinheit", "keyword": "Schlagwort"} + + +def _ai_classify_similars(result: dict[str, Any], model: str) -> dict[str, int]: + """ + KI ordnet jedes ähnliche Paar in drei Töpfe: + + * **gleich** – zweifelsfrei dasselbe (Schreibvarianten, fehlendes + Leerzeichen, Groß/Klein, Singular/Plural). Wird ANGEHAKT und die Richtung + vorgewählt (der bleibende Name ist der allgemeinere/korrekt geschriebene). + * **unklar** – könnte dasselbe sein, aber nicht sicher. Wird angezeigt, aber + nicht vorgewählt — der Mensch entscheidet. + * **verschieden** – zweifelsfrei verschiedene Sorten/Produkte. Wird + standardmäßig ausgeblendet (per Filter wieder einblendbar). + + Jedes Paar bekommt ``ai_verdict`` + ``ai_reason``; die Merge-Aktionen der + „gleich“-Paare erhalten Richtung und ``accept``. Nichts wird gelöscht. + """ + by_aid = {a["aid"]: a for a in result["actions"] + if a.get("action") == "merge" and a.get("origin") == "similar"} + paare = [] + for kind, data in result["kinds"].items(): + for s in data.get("similar", []): + paare.append({ + "id": s["aid"], + "art": _KIND_PLURAL.get(kind, kind), + "a": {"id": s["left"]["id"], "name": s["left"]["name"]}, + "b": {"id": s["right"]["id"], "name": s["right"]["name"]}, + }) + if not paare: + return {"geprueft": 0, "gleich": 0, "unklar": 0, "verschieden": 0} + + system = ( + "Du bist ein sorgfältiger Datenpfleger für eine Rezept-Datenbank und " + "ordnest jedes Namenspaar in genau eine Kategorie:\n" + "• „gleich“ = zweifelsfrei dasselbe: Schreib-/Tippvarianten, fehlendes " + "Leerzeichen, Groß-/Kleinschreibung, Singular/Plural oder dieselbe " + "Sache nur anders geschrieben.\n" + "• „verschieden“ = zweifelsfrei verschiedene Sorten, Typen, Grade oder " + "Produkte (z. B. „Weizenmehl 550“ vs. „Weizenmehl 1050“, „Frischkäse“ " + "vs. „körniger Frischkäse“, fettreduziert vs. normal, mit/ohne Zusatz " + "wie „in Öl“) oder klar verschiedene Zutaten.\n" + "• „unklar“ = könnte dasselbe sein, ist aber nicht sicher.\n" + "Sei konservativ: „gleich“ nur, wenn es wirklich zweifelsfrei dasselbe " + "ist. Bei „gleich“ nenne, welcher Eintrag BLEIBEN soll — der " + "allgemeinere bzw. korrekt geschriebene Name. Antworte nur mit JSON." + ) + user = ( + "Gib ein JSON-Objekt zurück: Schlüssel ist die „id“ des Paars, Wert ist " + '{"urteil": "gleich"|"unklar"|"verschieden", "keep_id": , "grund": "kurz"}.' + "\n\nPaare:\n" + json.dumps(paare, ensure_ascii=False) + ) + antwort = ai.chat_json( + [{"role": "system", "content": system}, {"role": "user", "content": user}], + model, + ) + if not isinstance(antwort, dict): + antwort = {} + + zähler = {"geprueft": len(paare), "gleich": 0, "unklar": 0, "verschieden": 0} + for kind, data in result["kinds"].items(): + for s in data.get("similar", []): + d = antwort.get(s["aid"]) if isinstance(antwort.get(s["aid"]), dict) else {} + urteil = d.get("urteil") + if urteil not in ("gleich", "unklar", "verschieden"): + urteil = "unklar" + grund = str(d.get("grund") or "").strip()[:200] + s["ai_verdict"] = urteil + s["ai_reason"] = grund + zähler[urteil] += 1 + + action = by_aid.get(s["aid"]) + if action is None: + continue + if urteil == "gleich": + keep = d.get("keep_id") + links = keep == s["left"]["id"] + if keep not in (s["left"]["id"], s["right"]["id"]): + links = True # Vorgabe: linken (i. d. R. kürzeren) behalten + action["target_id"] = s["left"]["id"] if links else s["right"]["id"] + action["target_name"] = s["left"]["name"] if links else s["right"]["name"] + action["source_id"] = s["right"]["id"] if links else s["left"]["id"] + action["source_name"] = s["right"]["name"] if links else s["left"]["name"] + action["accept"] = True + action["ai_reason"] = grund or "KI: dasselbe" + else: + action["accept"] = False + action["source_id"] = action["target_id"] = None + action["source_name"] = action["target_name"] = None + action["ai_reason"] = grund + action["ai_verdict"] = urteil + return zähler + + +def _fill_plurals(actions: list[dict[str, Any]], model: str, alle: bool) -> int: + """ + Lässt die KI korrekte deutsche Plurale eintragen. Standard: nur dort, wo + noch keiner gepflegt ist (manuell Gepflegtes wird nicht überschrieben). + Setzt plural_value + accept. Rückgabe: Anzahl vorbelegter Plurale. + + Einträge, die durch eine angehakte Zusammenführung ohnehin verschwinden, + werden übersprungen — ein Plural darauf liefe ins Leere. + """ + verschwindet = { + (a["kind"], a["source_id"]) for a in actions + if a["action"] == "merge" and a.get("accept") and isinstance(a.get("source_id"), int) + } + ziele = [a for a in actions if a["action"] == "set_plural" + and (a["kind"], a["source_id"]) not in verschwindet] + if not alle: + ziele = [a for a in ziele if not (a.get("plural_before_scan") or "").strip()] + if not ziele: + return 0 + + system = ( + "Du bist Experte für deutsche Grammatik und Lebensmittel. Gib zu jedem " + "Namen den korrekten Nominativ Plural. Für Maßeinheiten und nicht " + "zählbare Stoffe (z. B. Mehl, Wasser, Öl, Salz, Milliliter, Gramm) ist " + "der Plural meist gleich der Einzahl — gib dann den Namen unverändert " + "zurück. Behalte Groß-/Kleinschreibung und Zusätze bei. Antworte " + "ausschließlich mit JSON." + ) + gefuellt = 0 + for start in range(0, len(ziele), PLURAL_BATCH): + batch = ziele[start:start + PLURAL_BATCH] + namen = [{"id": a["aid"], + "art": _KIND_SINGULAR.get(a["kind"], a["kind"]), + "name": a["source_name"]} for a in batch] + user = ( + "Gib ein JSON-Objekt zurück: Schlüssel ist die „id“, Wert ist der " + "Plural als String.\n\nEinträge:\n" + + json.dumps(namen, ensure_ascii=False) + ) + antwort = ai.chat_json( + [{"role": "system", "content": system}, {"role": "user", "content": user}], + model, + ) + if not isinstance(antwort, dict): + continue + for a in batch: + pl = antwort.get(a["aid"]) + if isinstance(pl, str) and pl.strip(): + a["plural_value"] = pl.strip()[:120] + a["ai"] = True + # Nur anhaken, wenn es sich vom Ist-Stand unterscheidet. + if a["plural_value"] != (a.get("plural_before_scan") or "").strip(): + a["accept"] = True + gefuellt += 1 + return gefuellt + + +def vorbelegen(args: argparse.Namespace) -> int: + """KI schlägt Plurale für Zutaten und Einheiten vor (nur Vorbelegung).""" + file = Path(args.plan).expanduser().resolve() + if not file.is_file(): + raise SystemExit(f"Plandatei nicht gefunden: {file}") + plan = json.loads(file.read_text(encoding="utf-8")) + actions = plan.get("actions", []) + + out(f"KI-Plurale mit Modell {args.model} …") + gefuellt = _fill_plurals(actions, args.model, alle=args.alle_plurale) + out(f" {gefuellt} Plurale vorgeschlagen" + + (" (auch bestehende)" if args.alle_plurale else " (nur fehlende)")) + + plan["ai_prefilled_at"] = datetime.now(timezone.utc).isoformat() + file.write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + out() + out(f"Plan aktualisiert: {file}") + out("Die Vorschläge bitte prüfen, bevor du ausführst.") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Stammdaten aufräumen") + parser.add_argument("--base-url", default=None) + parser.add_argument("--token", default=None) + parser.add_argument("--auth-scheme", default=None) + parser.add_argument("--timeout", type=float, default=None) + parser.add_argument("--insecure", action="store_true") + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("pruefen", help="Dubletten und Unbenutztes suchen") + p.add_argument("--arten", default="", help="food,unit,keyword — leer = alle") + p.add_argument("--ki-filter", action="store_true", + help="ähnliche Paare per KI vorfiltern (definitiv verschiedene raus)") + p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5")) + + p = sub.add_parser("ausfuehren", help="Plan abarbeiten") + p.add_argument("--plan", required=True) + p.add_argument("--apply", action="store_true", help="wirklich ausführen") + p.add_argument("--ohne-rettung", action="store_true", + help="Nährwerte der Quelle NICHT vorher ans Ziel retten") + p.add_argument("--continue-on-error", action="store_true") + + p = sub.add_parser("zurueck", help="Einen Lauf zurückspielen") + p.add_argument("--lauf", required=True, help="Ordnername unter laeufe/") + p.add_argument("--apply", action="store_true", help="wirklich zurückspielen") + p.add_argument("--force", action="store_true", + help="auch bei zwischenzeitlichen Änderungen oder anderem Tandoor") + + p = sub.add_parser("vorbelegen", help="KI trifft eine Vorauswahl (ChatGPT)") + p.add_argument("--plan", required=True) + p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5")) + p.add_argument("--alle-plurale", action="store_true", + help="auch bestehende Plurale neu vorschlagen (Vorsicht: überschreibt)") + + p = sub.add_parser("probe", help="OpenAI-Verbindung testen") + p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5")) + args = parser.parse_args() + + # Diese Befehle brauchen kein Tandoor — nur OpenAI bzw. eine Plandatei. + if args.command == "probe": + if not os.environ.get("OPENAI_API_KEY"): + out("OPENAI_API_KEY fehlt — siehe Einstellungen.") + return 2 + ok, meldung = ai.probe(args.model) + out(meldung) + return 0 if ok else 1 + + if args.command == "vorbelegen": + if not os.environ.get("OPENAI_API_KEY"): + out("OPENAI_API_KEY fehlt — siehe Einstellungen.") + return 2 + return vorbelegen(args) + + try: + client = TandoorClient.from_env( + base_url=args.base_url, token=args.token, auth_scheme=args.auth_scheme, + timeout=args.timeout, verify=False if args.insecure else None, + ) + except TandoorError as exc: + out(f"Tandoor-Zugang fehlt: {exc}") + return 2 + + try: + if args.command == "pruefen": + plan = scan(client, args) + target = data_dir() / "plaene" / f"{stamp()}.json" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print_scan(plan) + out() + out(f"Plan: {target}") + out("Es wurde nichts verändert.") + return 0 + + if args.command == "ausfuehren": + return execute(client, args) + + if args.command == "zurueck": + return restore(client, args) + except TandoorError as exc: + out(f"Tandoor meldet: {exc}") + return 1 + except KeyboardInterrupt: + out("Abgebrochen.") + return 130 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/tandoor-conversions/TOOL-README.md b/plugins/tandoor-conversions/TOOL-README.md new file mode 100644 index 0000000..9a9ef02 --- /dev/null +++ b/plugins/tandoor-conversions/TOOL-README.md @@ -0,0 +1,53 @@ +# Einheiten-Umrechnungen (nach Gramm) + +Sorgt dafür, dass jede Zutat, die in Rezepten mit einer Nicht-Gramm-Einheit +vorkommt (z. B. „Stück“, „EL“, „ml“, „Prise“), eine Umrechnung **nach Gramm** +bekommt. Gramm ist die gemeinsame Basis — steht die einmal, kann Tandoor +zwischen allen Einheiten hin- und herrechnen und Mengen korrekt skalieren und +auf die Einkaufsliste bringen. + +## Ablauf + +1. **Prüfen** sucht die Gramm-Einheit („g“), liest die vorhandenen Umrechnungen + und alle Rezepte und ermittelt, welche Zutat mit welchen Einheiten benutzt + wird. Geprüft wird, ob eine Einheit — auch mehrstufig über vorhandene + Umrechnungen — bei Gramm ankommt. Kandidat wird, was das NICHT tut: + * Einheiten, die in Rezepten vorkommen und keinen Weg zu Gramm haben. + * Einheiten aus bereits vorhandenen Umrechnungen, deren „Kette“ nirgends bei + Gramm endet (z. B. „1 Stück = 3 EL“, aber weder Stück noch EL kennt Gramm) — + diese werden ebenfalls an Gramm angebunden. + Kann eine Einheit Gramm schon erreichen (direkt oder über eine Kette), wird + nichts doppelt angelegt. Reine Gewichtseinheiten (kg, mg, Pfund …) werden + übersprungen — die rechnet Tandoor ohnehin um. +2. **Werte vorschlagen** (ChatGPT) schätzt, wie viel Gramm eine typische Menge + wiegt (z. B. „1 Stück Zwiebel ≈ 110 g“, „1 EL Öl ≈ 9 g“). +3. Du prüfst und passt an — Ausgangsmenge und Gramm-Gewicht sind editierbar. +4. **Anlegen** legt die Umrechnungen in Tandoor an. Über **Sicherungen** lässt + sich jeder Lauf zurückspielen (die angelegten Umrechnungen werden gelöscht). + +## Wichtig: Schätzwerte + +Die KI-Werte sind **Schätzungen** für typische Größen. Tandoor rechnet damit +Rezepte und Einkaufslisten hoch — falsche Werte pflanzen sich fort. Deshalb +immer prüfen, bevor du sie anlegst. + +## Voraussetzung + +In Tandoor muss es eine Einheit **„g“** geben. Fehlt sie, meldet das Werkzeug +das und legt nichts an — dann zuerst in Tandoor eine Gramm-Einheit anlegen. + +## Sicherheit + +* Zutat und Einheit stehen fest (immer nach Gramm) — die Oberfläche kann nichts + Fremdes unterschieben; editierbar sind nur die beiden Mengen. +* Angelegte Umrechnungen sind über die Sicherungen wieder löschbar. + +## Kommandozeile + +``` +conversions.py pruefen +conversions.py vorschlagen --plan +conversions.py anwenden --plan [--apply] +conversions.py zurueck --lauf [--apply] +conversions.py probe +``` diff --git a/plugins/tandoor-conversions/backend.py b/plugins/tandoor-conversions/backend.py new file mode 100644 index 0000000..ab019e4 --- /dev/null +++ b/plugins/tandoor-conversions/backend.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +""" +Plugin-Adapter für „Einheiten-Umrechnungen“. + +Alles Verändernde läuft über tool/conversions.py im Job-Runner. Die Auswahl +steht vorher in einer Plandatei. Angelegte Umrechnungen sind über die +Sicherungen wieder löschbar. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from core.jobs import job_router +from core.backups import backup_router + +TOOL = "conversions.py" + + +class EditRequest(BaseModel): + accepted: list[dict[str, Any]] + + +class PrefillRequest(BaseModel): + plan: str + model: str | None = None + + +class ApplyRequest(BaseModel): + plan: str + apply: bool = True + + +class ProbeRequest(BaseModel): + model: str | None = None + + +def _num(value: Any) -> float | None: + try: + f = float(value) + return f if f > 0 else None + except (TypeError, ValueError): + return None + + +def create_app(ctx): + tool = ctx.path("tool", TOOL) + plans_dir = ctx.data_dir / "plaene" + plans_dir.mkdir(parents=True, exist_ok=True) + + def tool_env() -> dict: + env = ctx.settings.tool_env() + env["DATA_DIR"] = str(ctx.data_dir) + return env + + def resolve_plan(name: str) -> Path: + candidate = plans_dir / Path(name).name + if candidate.suffix != ".json" or not candidate.is_file(): + raise HTTPException(404, f"Plandatei „{name}“ gibt es nicht.") + return candidate + + app = FastAPI(title=ctx.meta.name, docs_url="/api/docs", redoc_url=None) + + @app.get("/", include_in_schema=False) + def index(): + return FileResponse(ctx.path("static", "index.html")) + + @app.get("/api/state") + def state() -> dict[str, Any]: + running = [j.info() for j in ctx.jobs.running(ctx.id)] + return { + "plans": sorted((p.name for p in plans_dir.glob("*.json")), reverse=True), + "tandoor": ctx.settings.status()["tandoor"], + "openai": ctx.settings.status()["openai"], + "running": running[0] if running else None, + } + + @app.get("/api/plans/{name}") + def read_plan(name: str) -> dict[str, Any]: + return json.loads(resolve_plan(name).read_text(encoding="utf-8")) + + @app.post("/api/plans/{name}") + def save_plan(name: str, request: EditRequest) -> dict[str, Any]: + """ + Übernimmt die Auswahl. Eng gehalten: je Kandidat lassen sich nur + `accept`, die Ausgangsmenge (`base_amount`) und das Gramm-Gewicht + (`grams`) ändern. Einheit und Zutat stehen fest (immer nach Gramm), die + Oberfläche kann daran nichts drehen. + """ + file = resolve_plan(name) + plan = json.loads(file.read_text(encoding="utf-8")) + by_aid = {k.get("aid"): k for k in plan.get("candidates", []) if k.get("aid")} + + gesetzt = 0 + for wish in request.accepted: + k = by_aid.get(wish.get("aid")) + if k is None: + continue + if "base_amount" in wish: + v = _num(wish["base_amount"]) + if v is not None: + k["base_amount"] = v + if "grams" in wish: + v = _num(wish["grams"]) + if v is not None: + k["grams"] = v + k["accept"] = bool(wish.get("accept")) + gesetzt += 1 + + file.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8") + offen = sum(1 for k in plan["candidates"] + if k.get("accept") and k.get("grams") and k.get("base_amount")) + return {"ok": True, "gesetzt": gesetzt, "umrechnungen": offen} + + # ---------------------------------------------------------------- Läufe + + @app.post("/api/run/pruefen") + async def run_scan() -> dict[str, Any]: + if ctx.jobs.running(ctx.id): + raise HTTPException(409, "Es läuft bereits etwas.") + if not ctx.settings.status()["tandoor"]: + raise HTTPException(400, "Tandoor-URL und Token fehlen.") + job = await ctx.jobs.start( + plugin=ctx.id, label="Umrechnungen prüfen", + argv=[sys.executable, str(tool), "pruefen"], + cwd=ctx.data_dir, env=tool_env(), + ) + return job.info() + + @app.post("/api/run/vorschlagen") + async def run_prefill(request: PrefillRequest) -> dict[str, Any]: + if ctx.jobs.running(ctx.id): + raise HTTPException(409, "Es läuft bereits etwas.") + if not ctx.settings.status()["openai"]: + raise HTTPException(400, "OpenAI-Key fehlt – siehe Einstellungen.") + file = resolve_plan(request.plan) + argv = [sys.executable, str(tool), "vorschlagen", "--plan", str(file)] + if request.model: + argv += ["--model", request.model] + job = await ctx.jobs.start( + plugin=ctx.id, label=f"KI schlägt vor: {file.stem}", + argv=argv, cwd=ctx.data_dir, env=tool_env(), + ) + return job.info() + + @app.post("/api/run/anwenden") + async def run_apply(request: ApplyRequest) -> dict[str, Any]: + if ctx.jobs.running(ctx.id): + raise HTTPException(409, "Es läuft bereits etwas.") + if not ctx.settings.status()["tandoor"]: + raise HTTPException(400, "Tandoor-URL und Token fehlen.") + file = resolve_plan(request.plan) + argv = [sys.executable, str(tool), "anwenden", "--plan", str(file), + "--continue-on-error"] + if request.apply: + argv.append("--apply") + job = await ctx.jobs.start( + plugin=ctx.id, + label=("Umrechnungen anlegen" if request.apply else "Trockenübung") + + f": {file.stem}", + argv=argv, cwd=ctx.data_dir, env=tool_env(), + ) + return job.info() + + @app.post("/api/run/probe") + async def run_probe(request: ProbeRequest) -> dict[str, Any]: + if ctx.jobs.running(ctx.id): + raise HTTPException(409, "Es läuft bereits etwas.") + if not ctx.settings.status()["openai"]: + raise HTTPException(400, "OpenAI-Key fehlt – siehe Einstellungen.") + argv = [sys.executable, str(tool), "probe"] + if request.model: + argv += ["--model", request.model] + job = await ctx.jobs.start( + plugin=ctx.id, label="OpenAI-Verbindung testen", + argv=argv, cwd=ctx.data_dir, env=tool_env(), + ) + return job.info() + + def restore_argv(run: str, apply: bool, force: bool) -> list[str]: + argv = [sys.executable, str(tool), "zurueck", "--lauf", run] + if apply: + argv.append("--apply") + return argv + + app.include_router(backup_router(ctx, "laeufe", restore_argv)) + app.include_router(job_router(ctx.jobs, ctx.id)) + return app diff --git a/plugins/tandoor-conversions/plugin.json b/plugins/tandoor-conversions/plugin.json new file mode 100644 index 0000000..862d6a4 --- /dev/null +++ b/plugins/tandoor-conversions/plugin.json @@ -0,0 +1,18 @@ +{ + "id": "tandoor-conversions", + "name": "Einheiten-Umrechnungen", + "summary": "Fehlende Umrechnungen finden und per KI-Schätzung eintragen", + "description": "Findet Zutaten, die in Rezepten mit mehreren, nicht ohne Weiteres umrechenbaren Einheiten vorkommen (z. B. Stück und g) und wofür noch keine Umrechnung hinterlegt ist. ChatGPT schlägt sinnvolle Werte vor — diese sind Schätzungen und vor dem Eintragen zu prüfen. Angelegte Umrechnungen sind über die Sicherungen wieder löschbar.", + "icon": "🔁", + "category": "Tandoor", + "version": "1.0.0", + "entrypoint": "backend:create_app", + "order": 49, + "requires": ["tandoor"], + "features": [ + "Findet fehlende, sinnvolle Umrechnungen aus der Rezeptnutzung", + "KI schlägt Werte vor — als Schätzung markiert", + "Angelegte Umrechnungen sind löschbar (rückspielbar)" + ], + "docs": "TOOL-README.md" +} diff --git a/plugins/tandoor-conversions/static/index.html b/plugins/tandoor-conversions/static/index.html new file mode 100644 index 0000000..3fec889 --- /dev/null +++ b/plugins/tandoor-conversions/static/index.html @@ -0,0 +1,343 @@ + + + + + +Einheiten-Umrechnungen + + + + + + + +
+
+

🔁 Einheiten-Umrechnungen

+

Sorgt dafür, dass jede Zutat, die mit einer Nicht-Gramm-Einheit vorkommt, + eine Umrechnung nach Gramm bekommt. ChatGPT schätzt die Werte — du prüfst.

+
+ +
+ +
+ + + + +
+ + +
+
+
+
+

Bestandsaufnahme

+

Liest Rezepte und vorhandene + Umrechnungen und prüft, welche Einheit noch nicht bei Gramm ankommt. + Verändert nichts.

+
+ +
+
+ + +
+
Bereit.
+
+
+
Noch nicht geprüft. Oben auf „Jetzt prüfen“.
+
+ + +
+
+

Werte per KI (ChatGPT)

+

ChatGPT schätzt, wie viel Gramm eine typische Menge wiegt + (z. B. „1 Stück Zwiebel ≈ 110 g“). Prüfen und freigeben unter „Anlegen“.

+
+ + + + +
+
Bereit.
+
+
+
+ + +
+
Erst prüfen (Tab 1).
+ +
+ + +
+
+ + + + diff --git a/plugins/tandoor-conversions/tool/conversions.py b/plugins/tandoor-conversions/tool/conversions.py new file mode 100644 index 0000000..7b41a1f --- /dev/null +++ b/plugins/tandoor-conversions/tool/conversions.py @@ -0,0 +1,501 @@ +# -*- coding: utf-8 -*- +""" +Einheiten-Umrechnungen — fehlende, sinnvolle Umrechnungen finden und eintragen. + +Sinnvoll ist eine Umrechnung vor allem dort, wo eine Zutat in Rezepten mit +mehreren, nicht ohne Weiteres ineinander umrechenbaren Einheiten vorkommt +(z. B. „Stück“ und „g“) und dafür noch keine Umrechnung hinterlegt ist. Ohne +sie kann Tandoor solche Mengen nicht skalieren oder auf die Einkaufsliste +bringen. + +WICHTIG: Die von der KI gelieferten Werte (z. B. „1 Zwiebel ≈ 110 g“) sind +SCHÄTZUNGEN. Sie sind vor dem Eintragen zu prüfen. + +Befehle: + pruefen Rezepte, Einheiten und vorhandene Umrechnungen lesen + vorschlagen ChatGPT schlägt Werte für die fehlenden Umrechnungen vor + anwenden Freigegebene Umrechnungen anlegen + zurueck Einen Lauf zurückspielen (löscht die angelegten Umrechnungen) + probe OpenAI-Verbindung testen +""" + +from __future__ import annotations + +import argparse +import json +import os +import secrets +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +_PARENTS = Path(__file__).resolve().parents +SUITE_ROOT = _PARENTS[3] if len(_PARENTS) > 3 else Path.cwd() +if str(SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(SUITE_ROOT)) + +from core.tandoor import TandoorClient, TandoorError # noqa: E402 +from core import backups # noqa: E402 +from core import ai # noqa: E402 + +BATCH = 30 + + +def out(text: str = "") -> None: + print(text, flush=True) + + +def data_dir() -> Path: + configured = os.environ.get("DATA_DIR", "").strip() + base = Path(configured) if configured else SUITE_ROOT / "data" / "tandoor-conversions" + base.mkdir(parents=True, exist_ok=True) + return base + + +def stamp() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def make_client(args: argparse.Namespace) -> TandoorClient: + base = args.base_url or os.environ.get("TANDOOR_URL", "") + token = args.token or os.environ.get("TANDOOR_TOKEN", "") + scheme = args.auth_scheme or os.environ.get("TANDOOR_AUTH_SCHEME", "Bearer") + return TandoorClient(base, token, auth_scheme=scheme, + verify=not args.insecure, timeout=args.timeout) + + +def _unit_of(ing: dict) -> tuple[int, str] | None: + u = ing.get("unit") + if isinstance(u, dict) and isinstance(u.get("id"), int): + return u["id"], (u.get("name") or "") + return None + + +# ------------------------------------------------------------------ Prüfen + +# Einheiten, die bereits „gewichtsartig“ und generisch nach Gramm umrechenbar +# sind — dafür braucht es keine food-spezifische Umrechnung. +_GRAM_COMPATIBLE = { + "g", "gramm", "gram", "gramme", "kg", "kilo", "kilogramm", "kilogram", + "mg", "milligramm", "dag", "dkg", "dekagramm", "pfund", +} + + +def _norm(name: str) -> str: + return (name or "").strip().casefold() + + +def _find_gram_unit(units: list[dict]) -> dict | None: + # Bevorzugt exakt „g“, sonst „gramm“/„gram“. + for wanted in ("g",): + for u in units: + if _norm(u.get("name")) == wanted: + return u + for u in units: + if _norm(u.get("name")) in ("gramm", "gram", "gramme"): + return u + return None + + +def scan(client: TandoorClient) -> dict[str, Any]: + out("Einheiten werden gelesen …") + units = client.list_objects("unit") + unit_name = {u["id"]: (u.get("name") or "") for u in units} + gram = _find_gram_unit(units) + if not gram: + out(" ⚠ Keine Gramm-Einheit („g“) in Tandoor gefunden — bitte zuerst " + "eine Einheit „g“ anlegen. Es kann nichts nach Gramm umgerechnet werden.") + gram_id = gram["id"] if gram else None + gram_name = gram.get("name") if gram else "g" + + # Alle Einheiten, die ohne food-spezifisches Wissen bei Gramm ankommen + # (Gramm selbst + generische Gewichtseinheiten). + gram_seed = {gram_id} if gram_id else set() + for u in units: + if _norm(u.get("name")) in _GRAM_COMPATIBLE: + gram_seed.add(u["id"]) + + out("Vorhandene Umrechnungen werden gelesen …") + existing = client.list_objects("unit-conversion") + # Kanten (welche Einheiten sind durch eine Umrechnung verbunden) je Zutat, + # plus globale Umrechnungen (food=None), die für alle Zutaten gelten. + edges: dict[int | None, list[tuple[int, int]]] = {} + conv_units: dict[int, set[int]] = {} # food_id -> Einheiten aus Umrechnungen + conv_food_name: dict[int, str] = {} + for uc in existing: + food = uc.get("food") or {} + fid = food.get("id") if isinstance(food, dict) else None + bu = (uc.get("base_unit") or {}).get("id") + cu = (uc.get("converted_unit") or {}).get("id") + if not (bu and cu): + continue + edges.setdefault(fid, []).append((bu, cu)) + if isinstance(fid, int): + conv_units.setdefault(fid, set()).update({bu, cu}) + if food.get("name"): + conv_food_name[fid] = food["name"] + out(f" {len(existing)} Umrechnungen vorhanden.") + + out("Rezepte werden gelesen (welche Zutat mit welchen Einheiten) …") + overview = client.list_objects("recipe") + used: dict[int, set[int]] = {} + food_name: dict[int, str] = {} + for i, entry in enumerate(overview, start=1): + try: + recipe = client.get_json(f"api/recipe/{entry['id']}/") + except TandoorError: + continue + for step in recipe.get("steps") or []: + for ing in step.get("ingredients") or []: + food = ing.get("food") or {} + fid = food.get("id") + unit = _unit_of(ing) + if isinstance(fid, int) and unit: + used.setdefault(fid, set()).add(unit[0]) + unit_name.setdefault(unit[0], unit[1]) + food_name.setdefault(fid, food.get("name") or "") + if i % 25 == 0 or i == len(overview): + out(f" {i}/{len(overview)}") + + def reaches_gram(food_id: int) -> set[int]: + """Alle Einheiten, die für diese Zutat (transitiv über vorhandene + Umrechnungen + globale) bei Gramm ankommen.""" + reached = set(gram_seed) + kanten = edges.get(food_id, []) + edges.get(None, []) + changed = True + while changed: + changed = False + for a, b in kanten: + if a in reached and b not in reached: + reached.add(b); changed = True + elif b in reached and a not in reached: + reached.add(a); changed = True + return reached + + counter = {"n": 0} + + def new_aid() -> str: + counter["n"] += 1 + return f"u{counter['n']:04d}-{secrets.token_hex(2)}" + + # Relevante Zutaten: kommen in Rezepten vor ODER haben bereits Umrechnungen. + alle_foods = set(used) | set(conv_units) + kandidaten = [] + ohne_gramm_einheit = False + for fid in alle_foods: + name = food_name.get(fid) or conv_food_name.get(fid) or f"#{fid}" + relevant = set(used.get(fid, set())) | set(conv_units.get(fid, set())) + reached = reaches_gram(fid) if gram_id else set() + for uid in sorted(relevant, key=lambda x: (unit_name.get(x, "") or "").casefold()): + uname = unit_name.get(uid, str(uid)) + if not gram_id: + ohne_gramm_einheit = True + continue + if uid == gram_id or _norm(uname) in _GRAM_COMPATIBLE: + continue # schon gewichtsartig / Gramm + if uid in reached: + continue # erreicht Gramm bereits (auch mehrstufig) + in_recipe = uid in used.get(fid, set()) + kandidaten.append({ + "aid": new_aid(), + "food_id": fid, + "food_name": name, + "unit": {"id": uid, "name": uname}, + "gram_id": gram_id, + "gram_name": gram_name, + "quelle": "rezept" if in_recipe else "umrechnung", + "base_amount": 1, + "grams": None, + "reason": "", + "ai": False, + "accept": False, + }) + kandidaten.sort(key=lambda k: ((k["food_name"] or "").casefold(), k["unit"]["name"])) + aus_umr = sum(1 for k in kandidaten if k["quelle"] == "umrechnung") + out(f" {len(kandidaten)} Einheiten ohne Weg zu Gramm " + f"({len(kandidaten) - aus_umr} aus Rezepten, {aus_umr} aus vorhandenen Umrechnungen).") + + return { + "created_at": datetime.now(timezone.utc).isoformat(), + "tandoor": client.base_url, + "existing_count": len(existing), + "gram_id": gram_id, + "gram_name": gram_name, + "candidates": kandidaten, + } + + +def print_scan(plan: dict[str, Any]) -> None: + out() + out("─" * 60) + out(f" {plan['existing_count']} vorhandene Umrechnungen · " + f"{len(plan['candidates'])} ohne Weg zu Gramm") + out("─" * 60) + for k in plan["candidates"][:20]: + marke = "" if k.get("quelle") == "rezept" else " (aus Umrechnung)" + out(f" {k['food_name']} [{k['food_id']}]: " + f"1 {k['unit']['name']} = ? {plan.get('gram_name', 'g')}{marke}") + if len(plan["candidates"]) > 20: + out(f" … und {len(plan['candidates']) - 20} weitere") + + +# ------------------------------------------------------------- KI-Vorschlag + +def vorschlagen(plan: dict[str, Any], model: str) -> int: + gram_name = plan.get("gram_name", "g") + ziele = [k for k in plan["candidates"] if k.get("grams") is None] + if not ziele: + out("Nichts offen.") + return 0 + + system = ( + "Du bist Experte für Lebensmittel und Küchenmengen. Für jede Zutat und " + "ihre Einheit gibst du an, wie viel GRAMM eine typische Menge dieser " + "Einheit wiegt (z. B. „1 Stück Zwiebel ≈ 110 g“, „1 EL Öl ≈ 9 g“, " + "„1 Prise Salz ≈ 0,4 g“). Die Werte sind Schätzungen für übliche Größen. " + "Gib pro Eintrag zusätzlich an, auf welche Ausgangsmenge (base_amount) " + "sich das Gramm-Gewicht bezieht — meist 1, bei sehr kleinen Mengen darf " + "es auch 100 sein (z. B. 100 ml). Kannst du eine Zutat/Einheit gar nicht " + "sinnvoll schätzen, setze grams auf null. Antworte nur mit JSON." + ) + gesetzt = 0 + for start in range(0, len(ziele), BATCH): + teil = ziele[start:start + BATCH] + anfrage = [{"id": k["aid"], "zutat": k["food_name"], "einheit": k["unit"]["name"]} + for k in teil] + user = ( + f"Zieleinheit ist immer „{gram_name}“ (Gramm). Gib ein JSON-Objekt " + "zurück: Schlüssel ist die „id“, Wert ist " + '{"base_amount": Zahl (meist 1), "grams": Gramm für diese ' + 'base_amount (oder null), "grund": "kurz"}.\n\nEinträge:\n' + + json.dumps(anfrage, ensure_ascii=False) + ) + antwort = ai.chat_json( + [{"role": "system", "content": system}, {"role": "user", "content": user}], + model, + ) + if not isinstance(antwort, dict): + continue + for k in teil: + d = antwort.get(k["aid"]) + k["ai"] = True + if not isinstance(d, dict): + continue + k["reason"] = str(d.get("grund") or "")[:200] + try: + grams = float(d["grams"]) + base = float(d.get("base_amount") or 1) + except (KeyError, TypeError, ValueError): + continue + if grams <= 0 or base <= 0: + continue + k["base_amount"] = base + k["grams"] = grams + k["accept"] = True + gesetzt += 1 + return gesetzt + + +# --------------------------------------------------------------- Anwenden + +def anwenden(client: TandoorClient, plan: dict[str, Any], args: argparse.Namespace) -> int: + gram_id = plan.get("gram_id") + gram_name = plan.get("gram_name", "g") + if not gram_id: + out("Keine Gramm-Einheit in Tandoor — bitte zuerst „g“ anlegen. Abbruch.") + return 1 + + ziele = [k for k in plan["candidates"] + if k.get("accept") and k.get("grams") and k.get("base_amount")] + if not ziele: + out("Nichts angehakt (oder ohne Wert).") + return 0 + + out(f"Modus: {'ANWENDEN' if args.apply else 'TROCKENÜBUNG'}") + out(f"Tandoor: {client.base_url}") + out(f"{len(ziele)} Umrechnungen nach {gram_name}") + out() + + run = data_dir() / "laeufe" / stamp() + if args.apply: + run.mkdir(parents=True, exist_ok=True) + manifest: dict[str, Any] = { + "created_at": datetime.now(timezone.utc).isoformat(), + "plugin": "tandoor-conversions", + "label": "Umrechnungen anlegen", + "tandoor": client.base_url, + "mode": "apply" if args.apply else "dry", + "steps": [], + } + + done, failed = 0, 0 + for nummer, k in enumerate(ziele, start=1): + text = (f"{k['base_amount']:g} {k['unit']['name']} " + f"= {k['grams']:g} {gram_name}") + prefix = f"[{nummer}/{len(ziele)}] „{k['food_name']}“: {text}" + + payload = { + "food": {"id": k["food_id"], "name": k["food_name"]}, + "base_amount": k["base_amount"], + "base_unit": {"id": k["unit"]["id"], "name": k["unit"]["name"]}, + "converted_amount": k["grams"], + "converted_unit": {"id": gram_id, "name": gram_name}, + } + + if not args.apply: + out(f"{prefix}: würde anlegen") + done += 1 + continue + + try: + created = client.post_json("api/unit-conversion/", payload) + except TandoorError as exc: + out(f"{prefix}: FEHLER — {exc}") + failed += 1 + if not args.continue_on_error: + return 1 + continue + + new_id = created.get("id") if isinstance(created, dict) else None + manifest["steps"].append({ + "n": nummer, + "action": "create_conversion", + "conversion_id": new_id, + "food_name": k["food_name"], + "text": text, + "restore_level": "voll" if new_id else "nein", + "status": "done", + }) + backups.write_manifest(run, manifest) + out(f"{prefix}: ✓ angelegt (id {new_id})") + done += 1 + + out() + out("─" * 60) + verb = "angelegt" if args.apply else "würden angelegt" + out(f" {done} {verb} · {failed} Fehler") + if args.apply and manifest["steps"]: + out(f" Sicherung: {run}") + out("─" * 60) + return 1 if failed else 0 + + +# ------------------------------------------------------------ Zurückspielen + +def restore(client: TandoorClient, args: argparse.Namespace) -> int: + run = backups.resolve_run(data_dir() / "laeufe", args.lauf) + manifest = backups.read_manifest(run) + if not manifest: + raise SystemExit("Kein manifest.json in diesem Lauf.") + + out(f"Modus: {'ZURÜCKSPIELEN' if args.apply else 'VORSCHAU'}") + zurueck, fehler = 0, 0 + for step in reversed(manifest.get("steps", [])): + if step.get("action") != "create_conversion": + continue + cid = step.get("conversion_id") + prefix = f"[{step['n']}] „{step['food_name']}“: {step.get('text','')}" + if not cid: + out(f"{prefix}: keine ID gespeichert — nicht löschbar") + continue + if not args.apply: + out(f"{prefix}: würde Umrechnung {cid} löschen") + zurueck += 1 + continue + try: + client.delete(f"api/unit-conversion/{cid}/") + out(f"{prefix}: ✓ gelöscht") + zurueck += 1 + except TandoorError as exc: + out(f"{prefix}: FEHLER — {exc}") + fehler += 1 + + if args.apply: + backups.mark_restored(run, {"zurueck": zurueck, "fehler": fehler}) + out(f"{zurueck} gelöscht · {fehler} Fehler") + return 1 if fehler else 0 + + +# ------------------------------------------------------------------- CLI + +def load_plan(path: str) -> tuple[Path, dict[str, Any]]: + file = Path(path).expanduser().resolve() + if not file.is_file(): + raise SystemExit(f"Plandatei nicht gefunden: {file}") + return file, json.loads(file.read_text(encoding="utf-8")) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Einheiten-Umrechnungen") + parser.add_argument("--base-url", default="") + parser.add_argument("--token", default="") + parser.add_argument("--auth-scheme", default="") + parser.add_argument("--timeout", type=float, default=45.0) + parser.add_argument("--insecure", action="store_true") + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("pruefen", help="fehlende Umrechnungen finden") + + p = sub.add_parser("vorschlagen", help="ChatGPT schlägt Werte vor") + p.add_argument("--plan", required=True) + p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5")) + + p = sub.add_parser("anwenden", help="freigegebene Umrechnungen anlegen") + p.add_argument("--plan", required=True) + p.add_argument("--apply", action="store_true") + p.add_argument("--continue-on-error", action="store_true") + + p = sub.add_parser("zurueck", help="einen Lauf zurückspielen") + p.add_argument("--lauf", required=True) + p.add_argument("--apply", action="store_true") + + p = sub.add_parser("probe", help="OpenAI-Verbindung testen") + p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5")) + + args = parser.parse_args() + + if args.command == "probe": + if not os.environ.get("OPENAI_API_KEY"): + out("OPENAI_API_KEY fehlt — siehe Einstellungen.") + return 2 + ok, meldung = ai.probe(args.model) + out(meldung) + return 0 if ok else 1 + + if args.command == "vorschlagen": + if not os.environ.get("OPENAI_API_KEY"): + out("OPENAI_API_KEY fehlt — siehe Einstellungen.") + return 2 + file, plan = load_plan(args.plan) + out(f"KI schlägt Umrechnungen vor (Modell {args.model}) …") + n = vorschlagen(plan, args.model) + plan["ai_prefilled_at"] = datetime.now(timezone.utc).isoformat() + file.write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + out(f"{n} Umrechnungen vorgeschlagen. Werte sind SCHÄTZUNGEN — bitte prüfen.") + return 0 + + client = make_client(args) + + if args.command == "pruefen": + plan = scan(client) + file = data_dir() / "plaene" / f"{stamp()}.json" + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8") + print_scan(plan) + out() + out(f"Plan: {file}") + return 0 + + if args.command == "anwenden": + _file, plan = load_plan(args.plan) + return anwenden(client, plan, args) + + if args.command == "zurueck": + return restore(client, args) + + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/tandoor-lint/TOOL-README.md b/plugins/tandoor-lint/TOOL-README.md new file mode 100644 index 0000000..d9ddbaf --- /dev/null +++ b/plugins/tandoor-lint/TOOL-README.md @@ -0,0 +1,42 @@ +# Rezept-Inventur + +Geht die Sammlung durch und meldet, was fehlt oder schief steht. + +**Dieses Werkzeug liest nur.** Es gibt keinen Schalter, der etwas verändert, +und keinen schreibenden Endpunkt. Ergebnis ist ein Bericht und eine CSV. + +## Was geprüft wird + +| Schwere | Prüfung | warum | +|---|---|---| +| Fehler | Keine Portionsangabe | ohne `servings` rechnet Tandoor nichts um | +| Fehler | Schritt ohne Anleitung | beim Kochen wertlos | +| Warnung | Keine Quelle | Herkunft nicht mehr nachvollziehbar | +| Warnung | Zutat ohne Einheit | Menge lässt sich nicht umrechnen | +| Warnung | Zutat ohne Menge | `amount` 0, ohne „keine Menge“ zu setzen | +| Warnung | Rezeptname doppelt | Verwechslungsgefahr | +| Hinweis | Kein Bild, kein Portionstext, keine Arbeitszeit, keine Schlagworte | | +| Hinweis | Zutat ohne Nährwerte | Rezeptwert bleibt unvollständig | +| Hinweis | Zutat / Einheit / Schlagwort ungenutzt | Karteileichen | + +Befunde lassen sich in der Oberfläche nach Prüfung filtern und als CSV +herunterladen. + +## Dauer + +Je Rezept eine Abfrage — die Rezeptliste allein enthält keine Schritte. Bei +einigen hundert Rezepten dauert der Durchlauf entsprechend. Der Fortschritt +steht im Log. + +## Ohne Suite + + export TANDOOR_URL=https://kitchen.example.de + export TANDOOR_TOKEN=... + python3 plugins/tandoor-lint/tool/lint.py pruefen + python3 plugins/tandoor-lint/tool/lint.py pruefen --nur err,warn + python3 plugins/tandoor-lint/tool/lint.py pruefen --ohne bild,arbeitszeit + +## Dateien + + data/tandoor-lint/bericht/bericht.json + data/tandoor-lint/bericht/befunde.csv diff --git a/plugins/tandoor-lint/backend.py b/plugins/tandoor-lint/backend.py new file mode 100644 index 0000000..81153bd --- /dev/null +++ b/plugins/tandoor-lint/backend.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +""" +Plugin-Adapter für die Rezept-Inventur. + +Nur lesend: Es gibt hier bewusst keinen Endpunkt, der etwas verändern könnte. +""" +from __future__ import annotations + +import json +import sys +from typing import Any + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field + +from core.jobs import job_router + +TOOL = "lint.py" +SEVERITIES = {"err", "warn", "info"} + + +class ScanRequest(BaseModel): + only: list[str] = Field(default_factory=list) + skip: list[str] = Field(default_factory=list) + insecure: bool = False + + +def create_app(ctx): + tool = ctx.path("tool", TOOL) + report_file = ctx.data_dir / "bericht" / "bericht.json" + csv_file = ctx.data_dir / "bericht" / "befunde.csv" + + def tool_env() -> dict: + """ + Umgebung für den Subprozess. + + Wichtig: DATA_DIR ausdrücklich mitgeben. Ohne das schreibt das Skript + seinen Rückfallpfad neben die Anwendung — im Container ist der + schreibgeschützt, und die Oberfläche würde den Bericht nie finden. + """ + env = ctx.settings.tool_env() + env["DATA_DIR"] = str(ctx.data_dir) + return env + + app = FastAPI(title=ctx.meta.name, docs_url="/api/docs", redoc_url=None) + + @app.get("/", include_in_schema=False) + def index(): + return FileResponse(ctx.path("static", "index.html")) + + @app.get("/api/state") + def state() -> dict[str, Any]: + report = None + if report_file.is_file(): + try: + report = json.loads(report_file.read_text(encoding="utf-8")) + except Exception: + report = None + running = [j.info() for j in ctx.jobs.running(ctx.id)] + return { + "report": report, + "tandoor": ctx.settings.status()["tandoor"], + "running": running[0] if running else None, + "has_csv": csv_file.is_file(), + } + + @app.get("/api/export.csv") + def export(): + if not csv_file.is_file(): + raise HTTPException(404, "Noch kein Bericht vorhanden.") + return FileResponse(csv_file, media_type="text/csv", filename="befunde.csv") + + @app.post("/api/run/scan") + async def run_scan(request: ScanRequest) -> dict[str, Any]: + if ctx.jobs.running(ctx.id): + raise HTTPException(409, "Die Inventur läuft bereits.") + if not ctx.settings.status()["tandoor"]: + raise HTTPException(400, "Tandoor-URL und Token fehlen – siehe Einstellungen.") + + argv = [sys.executable, str(tool)] + if request.insecure: + argv.append("--insecure") + argv.append("pruefen") + + bad = [s for s in request.only if s not in SEVERITIES] + if bad: + raise HTTPException(400, f"Unbekannter Schweregrad: {bad}") + if request.only: + argv += ["--nur", ",".join(request.only)] + if request.skip: + # Die Schlüssel prüft das Skript selbst; hier nur grob absichern. + clean = [s for s in request.skip if s.replace("_", "").isalnum()] + if clean: + argv += ["--ohne", ",".join(clean)] + + job = await ctx.jobs.start( + plugin=ctx.id, label="Inventur der Sammlung", argv=argv, + cwd=ctx.data_dir, env=tool_env(), + ) + return job.info() + + app.include_router(job_router(ctx.jobs, ctx.id)) + return app diff --git a/plugins/tandoor-lint/plugin.json b/plugins/tandoor-lint/plugin.json new file mode 100644 index 0000000..4198189 --- /dev/null +++ b/plugins/tandoor-lint/plugin.json @@ -0,0 +1,19 @@ +{ + "id": "tandoor-lint", + "name": "Rezept-Inventur", + "summary": "Findet fehlende Quellen, Bilder, Portionen, Einheiten, Dubletten", + "description": "Geht die ganze Sammlung durch und meldet, was fehlt oder schief steht: Rezepte ohne Quelle oder Portionsangabe, Zutaten ohne Einheit, doppelte Rezeptnamen, verwaiste Stammdaten. Liest ausschließlich – verändert nichts.", + "icon": "🔍", + "category": "Tandoor", + "version": "1.0.0", + "entrypoint": "backend:create_app", + "order": 15, + "requires": ["tandoor"], + "features": [ + "14 Prüfungen, nach Fehler / Warnung / Hinweis sortiert", + "Nur lesend – kein Schalter kann etwas verändern", + "Befunde filterbar, Export als CSV", + "Zeigt auch verwaiste Zutaten, Einheiten und Schlagworte" + ], + "docs": "TOOL-README.md" +} diff --git a/plugins/tandoor-lint/static/index.html b/plugins/tandoor-lint/static/index.html new file mode 100644 index 0000000..bad59a9 --- /dev/null +++ b/plugins/tandoor-lint/static/index.html @@ -0,0 +1,200 @@ + + + + + +Rezept-Inventur + + + + + + + +
+
+

Rezept-Inventur

+

Geht die Sammlung durch und meldet, was fehlt oder schief steht. + Dieses Werkzeug liest ausschließlich — es verändert in Tandoor nichts.

+
+ +
+ +
+
+
+

Durchsehen

+

+ Je Rezept eine Abfrage — bei vielen Rezepten dauert das einen Moment.

+
+ + +
+
Bereit.
+
+
+ +
Noch keine Inventur gelaufen.
+
+ + + + diff --git a/plugins/tandoor-lint/tool/lint.py b/plugins/tandoor-lint/tool/lint.py new file mode 100644 index 0000000..b8e2529 --- /dev/null +++ b/plugins/tandoor-lint/tool/lint.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Rezept-Inventur: findet Lücken und Ungereimtheiten in der Sammlung. + +Liest nur. Schreibt nichts nach Tandoor — weder jetzt noch mit irgendeinem +Schalter. Das Ergebnis ist ein Bericht und eine CSV-Tabelle. + + lint.py pruefen [--nur err,warn] [--ohne bild,zeit] + +Standalone: + python3 plugins/tandoor-lint/tool/lint.py pruefen +""" +from __future__ import annotations + +import argparse +import csv +import json +import os +import sys +from collections import Counter, defaultdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +SUITE_ROOT = Path(__file__).resolve().parents[3] +if str(SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(SUITE_ROOT)) + +from core.tandoor import TandoorClient, TandoorError, food_property_map # noqa: E402 +from core.foodmatch import comparable # noqa: E402 + + +def out(text: str = "") -> None: + print(text, flush=True) + + +def data_dir() -> Path: + configured = os.environ.get("DATA_DIR", "").strip() + base = Path(configured) if configured else SUITE_ROOT / "data" / "tandoor-lint" + base.mkdir(parents=True, exist_ok=True) + return base + + +# Jede Prüfung: Schlüssel, Schweregrad, Überschrift, was man tun sollte. +CHECKS: dict[str, dict[str, str]] = { + "quelle": {"severity": "warn", "title": "Keine Quelle hinterlegt", + "hint": "source_url leer — Herkunft später nicht mehr nachvollziehbar."}, + "bild": {"severity": "info", "title": "Kein Bild", + "hint": "Rezept ohne Bild geht in der Übersicht unter."}, + "portionen": {"severity": "err", "title": "Keine Portionsangabe", + "hint": "Ohne servings rechnet Tandoor Mengen und Nährwerte nicht um."}, + "portionstext": {"severity": "info", "title": "Kein Portionstext", + "hint": "servings_text leer — es steht dann nur eine nackte Zahl da."}, + "arbeitszeit": {"severity": "info", "title": "Keine Arbeitszeit", + "hint": "working_time 0 — Filter nach Zeit findet das Rezept nicht."}, + "schlagwort": {"severity": "info", "title": "Keine Schlagworte", + "hint": "Ohne Keywords über Filter praktisch unauffindbar."}, + "schritt_leer": {"severity": "err", "title": "Schritt ohne Anleitung", + "hint": "Ein Schritt ohne Text ist beim Kochen wertlos."}, + "zutat_einheit": {"severity": "warn", "title": "Zutat ohne Einheit", + "hint": "Menge ohne Einheit lässt sich nicht umrechnen."}, + "zutat_menge": {"severity": "warn", "title": "Zutat ohne Menge", + "hint": "amount 0, ohne dass „keine Menge“ gesetzt ist."}, + "zutat_naehr": {"severity": "info", "title": "Zutat ohne Nährwerte", + "hint": "Solange die fehlen, bleibt der Rezeptwert unvollständig."}, + "name_doppelt": {"severity": "warn", "title": "Rezeptname doppelt", + "hint": "Zwei Rezepte gleichen Namens — Verwechslungsgefahr."}, + "food_verwaist": {"severity": "info", "title": "Zutat in keinem Rezept", + "hint": "numrecipe 0 — Karteileiche im Stammdatenbestand."}, + "unit_verwaist": {"severity": "info", "title": "Einheit ungenutzt", + "hint": "Taucht in keinem Rezept auf."}, + "kw_verwaist": {"severity": "info", "title": "Schlagwort ungenutzt", + "hint": "Hängt an keinem Rezept."}, +} + +ORDER = {"err": 0, "warn": 1, "info": 2} + + +def finding(check: str, scope: str, target: str, target_id: Any, detail: str) -> dict[str, Any]: + meta = CHECKS[check] + return { + "check": check, "severity": meta["severity"], "title": meta["title"], + "hint": meta["hint"], "scope": scope, "target": target, + "target_id": target_id, "detail": detail, + } + + +def scan(client: TandoorClient, args: argparse.Namespace) -> dict[str, Any]: + skip = {s.strip() for s in (args.ohne or "").split(",") if s.strip()} + unknown = skip - set(CHECKS) + if unknown: + raise SystemExit(f"Unbekannte Prüfung in --ohne: {sorted(unknown)}") + + out("Stammdaten werden gelesen …") + foods = client.list_objects("food") + units = client.list_objects("unit") + keywords = client.list_objects("keyword") + out(f" {len(foods)} Zutaten · {len(units)} Einheiten · {len(keywords)} Schlagworte") + + out("Rezeptliste wird gelesen …") + overview = client.list_objects("recipe") + out(f" {len(overview)} Rezepte") + + food_props = {f["id"]: food_property_map(f) for f in foods} + prop_type_count = len(client.list_objects("property-type")) + findings: list[dict[str, Any]] = [] + + # ------------------------------------------------ doppelte Rezeptnamen + if "name_doppelt" not in skip: + by_name: dict[str, list[dict]] = defaultdict(list) + for r in overview: + by_name[comparable(r.get("name"))].append(r) + for name, group in by_name.items(): + if len(group) > 1: + ids = ", ".join(str(r["id"]) for r in group) + for r in group: + findings.append(finding( + "name_doppelt", "Rezept", r.get("name") or "?", r["id"], + f"{len(group)}× derselbe Name (IDs {ids})", + )) + + # ------------------------------------------------------ Rezept-Details + out("Rezepte werden einzeln geprüft …") + used_units: set[int] = set() + used_keywords: set[int] = set() + checked = 0 + unreadable: list[str] = [] + + for index, entry in enumerate(overview, start=1): + rid = entry.get("id") + try: + recipe = client.get_json(f"api/recipe/{rid}/") + except TandoorError as exc: + unreadable.append(f"{rid}: {exc}") + continue + checked += 1 + if index % 25 == 0 or index == len(overview): + out(f" {index}/{len(overview)}") + + name = recipe.get("name") or f"Rezept {rid}" + + if "quelle" not in skip and not (recipe.get("source_url") or "").strip(): + findings.append(finding("quelle", "Rezept", name, rid, "source_url ist leer")) + if "bild" not in skip and not recipe.get("image"): + findings.append(finding("bild", "Rezept", name, rid, "kein Bild hinterlegt")) + if "portionen" not in skip and not (recipe.get("servings") or 0): + findings.append(finding("portionen", "Rezept", name, rid, "servings ist 0")) + if "portionstext" not in skip and not (recipe.get("servings_text") or "").strip(): + findings.append(finding("portionstext", "Rezept", name, rid, "servings_text ist leer")) + if "arbeitszeit" not in skip and not (recipe.get("working_time") or 0): + findings.append(finding("arbeitszeit", "Rezept", name, rid, "working_time ist 0")) + + kws = recipe.get("keywords") or [] + for kw in kws: + if isinstance(kw, dict) and isinstance(kw.get("id"), int): + used_keywords.add(kw["id"]) + if "schlagwort" not in skip and not kws: + findings.append(finding("schlagwort", "Rezept", name, rid, "keine Keywords")) + + for step_no, step in enumerate(recipe.get("steps") or [], start=1): + if "schritt_leer" not in skip and not (step.get("instruction") or "").strip(): + findings.append(finding( + "schritt_leer", "Rezept", name, rid, f"Schritt {step_no} hat keinen Text")) + + for ing in step.get("ingredients") or []: + zutat = (ing.get("food") or {}).get("name") or "?" + food_id = (ing.get("food") or {}).get("id") + unit = ing.get("unit") + if isinstance(unit, dict) and isinstance(unit.get("id"), int): + used_units.add(unit["id"]) + + if ing.get("is_header"): + continue + no_amount = bool(ing.get("no_amount")) + if "zutat_einheit" not in skip and not unit and not no_amount: + findings.append(finding( + "zutat_einheit", "Rezept", name, rid, + f"Schritt {step_no}: „{zutat}“ hat keine Einheit")) + if "zutat_menge" not in skip and not (ing.get("amount") or 0) and not no_amount: + findings.append(finding( + "zutat_menge", "Rezept", name, rid, + f"Schritt {step_no}: „{zutat}“ hat keine Menge")) + if "zutat_naehr" not in skip and isinstance(food_id, int): + werte = food_props.get(food_id, {}) + fehlen = prop_type_count - sum(1 for v in werte.values() if v is not None) + if fehlen: + findings.append(finding( + "zutat_naehr", "Rezept", name, rid, + f"„{zutat}“ fehlen {fehlen} von {prop_type_count} Eigenschaften")) + + # ---------------------------------------------------------- Verwaistes + if "food_verwaist" not in skip: + for f in foods: + if not (f.get("numrecipe") or 0): + findings.append(finding( + "food_verwaist", "Zutat", f.get("name") or "?", f.get("id"), + "in keinem Rezept verwendet")) + if "unit_verwaist" not in skip: + for u in units: + if u.get("id") not in used_units: + findings.append(finding( + "unit_verwaist", "Einheit", u.get("name") or "?", u.get("id"), + "in keinem geprüften Rezept verwendet")) + if "kw_verwaist" not in skip: + for k in keywords: + if k.get("id") not in used_keywords: + findings.append(finding( + "kw_verwaist", "Schlagwort", k.get("name") or "?", k.get("id"), + "an keinem geprüften Rezept")) + + if args.nur: + wanted = {s.strip() for s in args.nur.split(",") if s.strip()} + findings = [f for f in findings if f["severity"] in wanted] + + findings.sort(key=lambda f: (ORDER[f["severity"]], f["check"], str(f["target"]).casefold())) + + per_check = Counter(f["check"] for f in findings) + per_severity = Counter(f["severity"] for f in findings) + betroffene = {(f["scope"], f["target_id"]) for f in findings} + + return { + "created_at": datetime.now(timezone.utc).isoformat(), + "summary": { + "recipes": len(overview), + "recipes_checked": checked, + "foods": len(foods), "units": len(units), "keywords": len(keywords), + "findings": len(findings), + "err": per_severity.get("err", 0), + "warn": per_severity.get("warn", 0), + "info": per_severity.get("info", 0), + "affected": len(betroffene), + "clean_recipes": len(overview) - len({ + f["target_id"] for f in findings if f["scope"] == "Rezept"}), + "unreadable": unreadable, + }, + "checks": [ + {"key": key, **meta, "count": per_check.get(key, 0), "skipped": key in skip} + for key, meta in CHECKS.items() + ], + "findings": findings, + } + + +def write_report(report: dict[str, Any], target: Path) -> None: + target.mkdir(parents=True, exist_ok=True) + (target / "bericht.json").write_text( + json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + with (target / "befunde.csv").open("w", encoding="utf-8", newline="") as handle: + writer = csv.writer(handle, delimiter=";") + writer.writerow(["Schweregrad", "Prüfung", "Was", "Bereich", "Name", "ID", "Detail"]) + for f in report["findings"]: + writer.writerow([f["severity"], f["check"], f["title"], + f["scope"], f["target"], f["target_id"], f["detail"]]) + + +def print_report(report: dict[str, Any]) -> None: + s = report["summary"] + out() + out("─" * 64) + out(f" Rezepte geprüft {s['recipes_checked']} von {s['recipes']}") + out(f" ohne jeden Befund {s['clean_recipes']}") + out(f" Befunde gesamt {s['findings']}") + out(f" Fehler {s['err']}") + out(f" Warnungen {s['warn']}") + out(f" Hinweise {s['info']}") + out("─" * 64) + out() + for check in sorted(report["checks"], key=lambda c: (ORDER[c["severity"]], -c["count"])): + if check["skipped"]: + out(f" {'übersprungen':<12} {check['title']}") + elif check["count"]: + out(f" {check['severity']:<5} {check['count']:>4} × {check['title']}") + out() + hart = [f for f in report["findings"] if f["severity"] == "err"][:15] + if hart: + out(" Fehler zuerst:") + for f in hart: + out(f" {f['scope']} „{f['target']}“ [{f['target_id']}]: {f['detail']}") + if s["unreadable"]: + out() + out(f" {len(s['unreadable'])} Rezepte waren nicht lesbar:") + for line in s["unreadable"][:5]: + out(f" {line[:110]}") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Rezept-Inventur (nur lesend)") + parser.add_argument("--base-url", default=None) + parser.add_argument("--token", default=None) + parser.add_argument("--auth-scheme", default=None) + parser.add_argument("--timeout", type=float, default=None) + parser.add_argument("--insecure", action="store_true") + sub = parser.add_subparsers(dest="command", required=True) + p = sub.add_parser("pruefen", help="Sammlung durchsehen") + p.add_argument("--nur", default="", help="nur diese Schweregrade: err,warn,info") + p.add_argument("--ohne", default="", help="diese Prüfungen auslassen, kommagetrennt") + args = parser.parse_args() + + try: + client = TandoorClient.from_env( + base_url=args.base_url, token=args.token, auth_scheme=args.auth_scheme, + timeout=args.timeout, verify=False if args.insecure else None, + ) + except TandoorError as exc: + out(f"Tandoor-Zugang fehlt: {exc}") + return 2 + + try: + report = scan(client, args) + except TandoorError as exc: + out(f"Tandoor meldet: {exc}") + return 1 + except KeyboardInterrupt: + out("Abgebrochen.") + return 130 + + target = data_dir() / "bericht" + write_report(report, target) + print_report(report) + out() + out(f"Bericht: {target / 'bericht.json'}") + out(f"Tabelle: {target / 'befunde.csv'}") + out() + out("Es wurde nichts verändert — dieses Werkzeug liest nur.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/tandoor-nutrition/TOOL-README.md b/plugins/tandoor-nutrition/TOOL-README.md new file mode 100644 index 0000000..af00060 --- /dev/null +++ b/plugins/tandoor-nutrition/TOOL-README.md @@ -0,0 +1,70 @@ +# Nährwerte vervollständigen + +Findet Zutaten, bei denen Eigenschaften fehlen, lässt die Lücken schätzen und +schreibt sie nach Prüfung zurück. + +## Die drei Schritte + +**1 · Prüfen** liest alle Zutaten und Eigenschaften und zeigt, wo was fehlt. +Sortiert nach Rezeptzahl — eine Lücke bei „Zwiebel“ (18 Rezepte) wiegt +schwerer als bei „Yuzukosho“ (0 Rezepte). Verändert nichts. + +**2 · Vorschlagen** lässt ein Sprachmodell die fehlenden Werte je 100 g +schätzen. Geht in Blöcken zu 20 Zutaten an OpenAI. Schreibt eine +Vorschlagsdatei, in Tandoor passiert nichts. + +**3 · Übernehmen** zeigt jeden Vorschlag mit Häkchen und änderbarem Wert. +Erst „Trockenübung“, dann „Übernehmen“. Von jeder geänderten Zutat wird der +Zustand vorher und nachher gesichert. + +## Was die Zahlen sind — und was nicht + +Schätzungen eines Sprachmodells. Für Hausgebrauch und Größenordnung gedacht, +keine Laboranalyse. Wer Nährwerte medizinisch braucht, prüft sie besser gegen +eine echte Datenbank. Deshalb ist jeder Wert vor dem Schreiben editierbar. + +## Wie es mit Tandoor umgeht + +* Vorhandene Werte werden **nie** überschrieben (außer man hakt es ausdrücklich an). +* Tandoor ersetzt beim `PATCH` die komplette `properties`-Liste. Das Tool + schickt deshalb immer den vollständigen Bestand mit — sonst fiele weg, was + schon da war. +* Zutaten ohne Bezugsmenge bekommen sie mitgesetzt (Vorgabe: 100 g). Ohne die + kann Tandoor keine Rezeptwerte rechnen. + +## Ohne Suite + + export TANDOOR_URL=https://kitchen.example.de + export TANDOOR_TOKEN=... + export OPENAI_API_KEY=... + + python3 plugins/tandoor-nutrition/tool/nutrition.py pruefen + python3 plugins/tandoor-nutrition/tool/nutrition.py vorschlagen --limit 20 + python3 plugins/tandoor-nutrition/tool/nutrition.py uebernehmen \ + --vorschlag data/tandoor-nutrition/vorschlaege/.json --apply + +## Dateien + + data/tandoor-nutrition/bericht/bericht.json letzte Prüfung + data/tandoor-nutrition/bericht/luecken.csv dieselbe als Tabelle + data/tandoor-nutrition/vorschlaege/.json Vorschläge + data/tandoor-nutrition/laeufe// Sicherungen je Übernahme + +## Wenn „Vorschlagen“ nur „ohne Ergebnis“ liefert + +Kommt ein Lauf mit 0 Vorschlägen und „N ohne Ergebnis“ zurück, ist meist die +Modell-Abfrage grundsätzlich gescheitert, nicht an einzelnen Zutaten. Der erste +Fehler steht jetzt in der Vorschlagsdatei (`error`) und im Übernehmen-Reiter. + +Schnellster Weg zur Ursache: im Reiter „2 · Vorschlagen“ auf **Verbindung +testen** (oder `nutrition.py probe`). Das macht eine einzelne Beispiel-Abfrage +und zeigt den vollständigen Fehler. Häufige Ursachen: + +* **Modellname** nicht vorhanden oder nicht freigeschaltet → in den + Einstellungen ein anderes Modell setzen (`OPENAI_MODEL`). +* **API-Key** wird abgelehnt → `OPENAI_API_KEY` prüfen. +* **Guthaben/Kontingent** erschöpft. +* **Aufruf-Parameter**: Neuere Modelle lehnen `temperature` ungleich dem + Standard oder `response_format` ab. Das Tool probiert solche Fälle inzwischen + selbst mildere Varianten durch und merkt sich die funktionierende — hier ist + also normalerweise nichts mehr zu tun. diff --git a/plugins/tandoor-nutrition/backend.py b/plugins/tandoor-nutrition/backend.py new file mode 100644 index 0000000..7def877 --- /dev/null +++ b/plugins/tandoor-nutrition/backend.py @@ -0,0 +1,245 @@ +# -*- coding: utf-8 -*- +""" +Plugin-Adapter für „Nährwerte vervollständigen“. + +Das Backend hält selbst keine Logik: Es startet tool/nutrition.py über den +Job-Runner und liest die Dateien, die das Skript hinterlässt. Dadurch ist +dasselbe Werkzeug auch ohne Suite benutzbar und die Oberfläche zeigt +zwangsläufig das, was auch geschrieben würde. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field + +from core.jobs import job_router +from core.backups import backup_router + +TOOL = "nutrition.py" + + +class ScanRequest(BaseModel): + insecure: bool = False + + +class ProposeRequest(BaseModel): + foods: list[int] = Field(default_factory=list) + properties: list[int] = Field(default_factory=list) + limit: int = Field(default=0, ge=0, le=2000) + overwrite: bool = False + model: str | None = None + insecure: bool = False + + +class ProbeRequest(BaseModel): + model: str | None = None + + +class ApplyRequest(BaseModel): + proposal: str + apply: bool = False + overwrite: bool = False + continue_on_error: bool = True + base_amount: float = Field(default=100.0, gt=0, le=10000) + base_unit: str = Field(default="g", min_length=1, max_length=40) + insecure: bool = False + + +class EditRequest(BaseModel): + """Die in der Oberfläche geprüften Werte zurückschreiben.""" + proposals: list[dict[str, Any]] + + +def create_app(ctx): + tool = ctx.path("tool", TOOL) + report_file = ctx.data_dir / "bericht" / "bericht.json" + proposals_dir = ctx.data_dir / "vorschlaege" + proposals_dir.mkdir(parents=True, exist_ok=True) + + def tool_env() -> dict: + """ + Umgebung für den Subprozess. + + Wichtig: DATA_DIR ausdrücklich mitgeben. Ohne das schreibt das Skript + seinen Rückfallpfad neben die Anwendung — im Container ist der + schreibgeschützt, und die Oberfläche würde den Bericht nie finden. + """ + env = ctx.settings.tool_env() + env["DATA_DIR"] = str(ctx.data_dir) + return env + + def base_argv(insecure: bool) -> list[str]: + argv = [sys.executable, str(tool)] + if insecure: + argv.append("--insecure") + return argv + + def resolve_proposal(name: str) -> Path: + candidate = proposals_dir / Path(name).name + if candidate.suffix != ".json" or not candidate.is_file(): + raise HTTPException(404, f"Vorschlagsdatei „{name}“ gibt es nicht.") + return candidate + + def guard() -> None: + if ctx.jobs.running(ctx.id): + raise HTTPException(409, "Es läuft bereits etwas. Bitte abwarten.") + if not ctx.settings.status()["tandoor"]: + raise HTTPException(400, "Tandoor-URL und Token fehlen – siehe Einstellungen.") + + app = FastAPI(title=ctx.meta.name, docs_url="/api/docs", redoc_url=None) + + @app.get("/", include_in_schema=False) + def index(): + return FileResponse(ctx.path("static", "index.html")) + + @app.get("/api/state") + def state() -> dict[str, Any]: + report = None + if report_file.is_file(): + try: + report = json.loads(report_file.read_text(encoding="utf-8")) + except Exception: + report = None + running = [j.info() for j in ctx.jobs.running(ctx.id)] + files = sorted( + (p.name for p in proposals_dir.glob("*.json")), reverse=True + ) + return { + "report": report, + "proposals": files, + "tandoor": ctx.settings.status()["tandoor"], + "openai": ctx.settings.status()["openai"], + "model": ctx.settings.get("OPENAI_MODEL") or "gpt-5.5", + "running": running[0] if running else None, + "data_dir": str(ctx.data_dir), + } + + @app.get("/api/proposals/{name}") + def read_proposal(name: str) -> dict[str, Any]: + return json.loads(resolve_proposal(name).read_text(encoding="utf-8")) + + @app.post("/api/proposals/{name}") + def save_proposal(name: str, request: EditRequest) -> dict[str, Any]: + """ + Speichert die in der Oberfläche geprüften und ggf. korrigierten Werte. + + Es werden nur Häkchen und Zahlen übernommen — welche Zutat und welche + Eigenschaft gemeint ist, bleibt das, was das Skript geschrieben hat. + """ + file = resolve_proposal(name) + payload = json.loads(file.read_text(encoding="utf-8")) + by_id = {p["food_id"]: p for p in payload.get("proposals", [])} + + for edited in request.proposals: + original = by_id.get(edited.get("food_id")) + if original is None: + continue + original["accept"] = bool(edited.get("accept", True)) + werte = edited.get("values") or {} + clean: dict[str, float] = {} + for key, value in werte.items(): + if key in original["values"]: + try: + number = float(value) + except (TypeError, ValueError): + continue + if 0 <= number <= 100000: + clean[key] = round(number, 2) + if clean: + original["values"] = clean + + file.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + angehakt = sum(1 for p in payload["proposals"] if p.get("accept", True)) + return {"ok": True, "accepted": angehakt, "total": len(payload["proposals"])} + + @app.post("/api/run/scan") + async def run_scan(request: ScanRequest) -> dict[str, Any]: + guard() + job = await ctx.jobs.start( + plugin=ctx.id, label="Prüfen: fehlende Nährwerte", + argv=base_argv(request.insecure) + ["pruefen"], + cwd=ctx.data_dir, env=tool_env(), + ) + return job.info() + + @app.post("/api/run/propose") + async def run_propose(request: ProposeRequest) -> dict[str, Any]: + guard() + if not ctx.settings.status()["openai"]: + raise HTTPException(400, "OpenAI-Key fehlt – siehe Einstellungen.") + + argv = base_argv(request.insecure) + ["vorschlagen"] + if request.foods: + argv += ["--foods", ",".join(str(int(i)) for i in request.foods)] + if request.properties: + argv += ["--properties", ",".join(str(int(i)) for i in request.properties)] + if request.limit: + argv += ["--limit", str(request.limit)] + if request.overwrite: + argv.append("--overwrite") + if request.model: + argv += ["--model", request.model] + + scope = f"{len(request.foods)} Zutaten" if request.foods else "alle Lücken" + job = await ctx.jobs.start( + plugin=ctx.id, label=f"Vorschlagen: {scope}", argv=argv, + cwd=ctx.data_dir, env=tool_env(), + ) + return job.info() + + @app.post("/api/run/probe") + async def run_probe(request: ProbeRequest) -> dict[str, Any]: + # Kein Tandoor nötig — nur OpenAI. Eine einzelne Testabfrage. + if ctx.jobs.running(ctx.id): + raise HTTPException(409, "Es läuft bereits etwas.") + if not ctx.settings.status()["openai"]: + raise HTTPException(400, "OpenAI-Key fehlt – siehe Einstellungen.") + argv = [sys.executable, str(tool), "probe"] + if request.model: + argv += ["--model", request.model] + job = await ctx.jobs.start( + plugin=ctx.id, label="OpenAI-Verbindung testen", argv=argv, + cwd=ctx.data_dir, env=tool_env(), + ) + return job.info() + + @app.post("/api/run/apply") + async def run_apply(request: ApplyRequest) -> dict[str, Any]: + guard() + file = resolve_proposal(request.proposal) + argv = base_argv(request.insecure) + ["uebernehmen", "--vorschlag", str(file)] + if request.apply: + argv.append("--apply") + if request.overwrite: + argv.append("--overwrite") + if request.continue_on_error: + argv.append("--continue-on-error") + argv += ["--base-amount", str(request.base_amount), + "--base-unit", request.base_unit] + + job = await ctx.jobs.start( + plugin=ctx.id, + label=("Übernehmen" if request.apply else "Trockenübung") + f": {file.stem}", + argv=argv, cwd=ctx.data_dir, env=tool_env(), + ) + return job.info() + + def restore_argv(run: str, apply: bool, force: bool) -> list[str]: + argv = [sys.executable, str(tool), "zurueck", "--lauf", run] + if apply: + argv.append("--apply") + if force: + argv.append("--force") + return argv + + app.include_router(backup_router(ctx, "laeufe", restore_argv)) + app.include_router(job_router(ctx.jobs, ctx.id)) + return app diff --git a/plugins/tandoor-nutrition/plugin.json b/plugins/tandoor-nutrition/plugin.json new file mode 100644 index 0000000..a728cdb --- /dev/null +++ b/plugins/tandoor-nutrition/plugin.json @@ -0,0 +1,19 @@ +{ + "id": "tandoor-nutrition", + "name": "Nährwerte vervollständigen", + "summary": "Fehlende Zutaten-Eigenschaften finden, schätzen, übernehmen", + "description": "Zeigt, bei welchen Zutaten Ballaststoffe, Proteine, Fett, Kohlenhydrate, Kalorien oder Zucker fehlen, lässt die Lücken je 100 g schätzen und schreibt sie nach Prüfung zurück. Rezept-Nährwerte rechnet Tandoor danach selbst.", + "icon": "🥑", + "category": "Tandoor", + "version": "1.0.0", + "entrypoint": "backend:create_app", + "order": 50, + "requires": ["tandoor", "openai"], + "features": [ + "Abdeckung je Eigenschaft auf einen Blick", + "Lücken zuerst dort, wo viele Rezepte dranhängen", + "Jeder Wert vor dem Schreiben prüf- und änderbar", + "Setzt fehlende Bezugsmengen gleich mit" + ], + "docs": "TOOL-README.md" +} diff --git a/plugins/tandoor-nutrition/static/index.html b/plugins/tandoor-nutrition/static/index.html new file mode 100644 index 0000000..b66e124 --- /dev/null +++ b/plugins/tandoor-nutrition/static/index.html @@ -0,0 +1,565 @@ + + + + + +Nährwerte vervollständigen + + + + + + + + +
+
+

Nährwerte vervollständigen

+

Findet Zutaten, bei denen Eigenschaften fehlen, lässt die Lücken + schätzen und schreibt sie nach Prüfung zurück. Rezept-Nährwerte rechnet + Tandoor danach selbst aus.

+
+ +
+ +
+ + + + +
+ + +
+
+
+
+

Bestandsaufnahme

+

Liest alle Zutaten und Eigenschaften. Verändert nichts.

+
+ +
+
Bereit.
+
+
+ +
Noch nicht geprüft. Oben auf „Jetzt prüfen“.
+
+ + +
+
+

Was soll geschätzt werden?

+

Ein Sprachmodell schätzt die fehlenden Werte je 100 g. + Nach Tandoor geschrieben wird hier noch nichts.

+ + +
+ + +
+ nur Lücken + nur Lücken in benutzten Zutaten + Auswahl unten +
+ +
+
+ + +
+
+ + +
+
+ + +
+ + + + +
+

„Verbindung testen“ macht eine + einzelne Beispiel-Abfrage und zeigt, ob das Modell erreichbar ist und + antwortet — nützlich, wenn ein Lauf „ohne Ergebnis“ zurückkommt.

+
Bereit.
+
+
+ +
+

Zutaten auswählen

+
+ + + + 0 +
+
+
+
+ + +
+
+
+
+ + +
+ +
+

+
+ +
+ Die Werte sind Schätzungen eines Sprachmodells, keine Laboranalysen. + Für Hausgebrauch und Größenordnung gedacht. Bitte vor dem Übernehmen + durchsehen — falsche Zahlen fallen später kaum noch auf. +
+ +
+
+

Vorschläge prüfen

+ + + 0 +
+
+ +
+
+ + +
+
+ + +
+
+

Wird nur bei Zutaten gesetzt, die noch keine Bezugsmenge haben.

+ +
+ + + + +
+
Bereit.
+
+
+
+ + +
+
+ + + + diff --git a/plugins/tandoor-nutrition/tool/nutrition.py b/plugins/tandoor-nutrition/tool/nutrition.py new file mode 100644 index 0000000..d9ba367 --- /dev/null +++ b/plugins/tandoor-nutrition/tool/nutrition.py @@ -0,0 +1,1142 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Nährwerte der Tandoor-Zutaten prüfen und vervollständigen. + +Drei Schritte, jeder einzeln aufrufbar: + + nutrition.py pruefen + Liest alle Foods und Eigenschaften und schreibt einen Bericht: + Wo fehlt was? Welche Lücken tun weh (= stecken in vielen Rezepten)? + Verändert nichts. + + nutrition.py vorschlagen --foods 1,2,3 + Lässt fehlende Werte von OpenAI je 100 g schätzen und schreibt sie in + eine Vorschlagsdatei. Verändert in Tandoor nichts. + + nutrition.py uebernehmen --vorschlag + Schreibt die Werte aus der Vorschlagsdatei per PATCH nach Tandoor. + Ohne --apply nur eine Trockenübung. + + nutrition.py selftest + Prüft lokal, dass numerische Nullwerte beim Lesen und Schreiben erhalten + bleiben. + +Die Werte sind Schätzungen eines Sprachmodells, keine Laboranalysen. Sie sind +für Hausgebrauch und Größenordnung gedacht — wer Nährwerte medizinisch braucht, +sollte sie gegen eine echte Datenbank prüfen. + +Standalone: + python3 plugins/tandoor-nutrition/tool/nutrition.py pruefen +""" +from __future__ import annotations + +import argparse +import csv +import json +import math +import os +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +_PARENTS = Path(__file__).resolve().parents +SUITE_ROOT = _PARENTS[3] if len(_PARENTS) > 3 else Path.cwd() +if str(SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(SUITE_ROOT)) + +from core.tandoor import ( # noqa: E402 + TandoorClient, + TandoorError, +) +from core import backups # noqa: E402 + +BATCH = 20 + + +_MISSING = object() + + +def _number_or_none(value: Any) -> float | None: + """ + Liefert eine endliche, nichtnegative Zahl oder ``None``. + + Wichtig: Numerische Null ist ein gültiger Nährwert und darf niemals über + eine Wahrheitsprüfung wie ``if value`` verworfen werden. Auch numerische + Strings aus APIs bzw. JSON-Dateien werden akzeptiert. + """ + if value is None or isinstance(value, bool): + return None + + if isinstance(value, (int, float)): + number = float(value) + elif isinstance(value, str): + raw = value.strip() + if not raw: + return None + # Dezimalkomma tolerieren, sofern nicht bereits ein Punkt vorkommt. + if "," in raw and "." not in raw: + raw = raw.replace(",", ".") + try: + number = float(raw) + except ValueError: + return None + else: + return None + + if not math.isfinite(number) or number < 0: + return None + return number + + +def _property_type_id(prop: dict[str, Any]) -> int | None: + raw_type = prop.get("property_type") + raw_id = raw_type.get("id") if isinstance(raw_type, dict) else raw_type + try: + return int(raw_id) + except (TypeError, ValueError): + return None + + +def food_property_map(food: dict[str, Any]) -> dict[int, float]: + """Liest Tandoor-Eigenschaften nullwertsicher als ``Typ-ID → Wert``.""" + values: dict[int, float] = {} + for prop in food.get("properties") or []: + if not isinstance(prop, dict): + continue + type_id = _property_type_id(prop) + amount = _number_or_none(prop.get("property_amount")) + if type_id is not None and amount is not None: + # 0.0 wird ausdrücklich übernommen und nicht als fehlend behandelt. + values[type_id] = amount + return values + + +def merged_properties_payload( + food: dict[str, Any], + updates: dict[int, float], +) -> list[dict[str, Any]]: + """ + Baut die vollständige Tandoor-Property-Liste ohne Truthy/Falsy-Fallen. + + Vorhandene IDs und Property-Type-Namen bleiben erhalten. Leere bzw. + ungültige Alt-Einträge werden nicht erneut übertragen. Neue Werte von 0 + werden als ``0.0`` in den Payload aufgenommen. + """ + normalised: dict[int, float] = {} + for raw_type_id, raw_amount in updates.items(): + try: + type_id = int(raw_type_id) + except (TypeError, ValueError) as exc: + raise ValueError(f"Ungültige Eigenschafts-ID: {raw_type_id!r}") from exc + amount = _number_or_none(raw_amount) + if amount is None: + raise ValueError( + f"Ungültiger Nährwert für Eigenschaft {type_id}: {raw_amount!r}" + ) + normalised[type_id] = amount + + result: list[dict[str, Any]] = [] + seen: set[int] = set() + + for prop in food.get("properties") or []: + if not isinstance(prop, dict): + continue + type_id = _property_type_id(prop) + if type_id is None or type_id in seen: + continue + + if type_id in normalised: + amount = normalised[type_id] + else: + amount = _number_or_none(prop.get("property_amount")) + if amount is None: + continue + + raw_type = prop.get("property_type") + type_payload: dict[str, Any] = {"id": type_id} + if isinstance(raw_type, dict): + name = str(raw_type.get("name") or "").strip() + if name: + type_payload["name"] = name + + entry: dict[str, Any] = { + "property_amount": amount, + "property_type": type_payload, + } + try: + property_id = int(prop.get("id")) + except (TypeError, ValueError): + property_id = None + if property_id is not None: + entry["id"] = property_id + + result.append(entry) + seen.add(type_id) + + for type_id, amount in normalised.items(): + if type_id in seen: + continue + result.append({ + "property_amount": amount, + "property_type": {"id": type_id}, + }) + + return result + + +def _normalise_proposal_values( + values: Any, +) -> tuple[dict[int, float], list[str]]: + """Validiert Vorschlagswerte. Null bleibt erhalten, nur echte Lücken fallen weg.""" + valid: dict[int, float] = {} + invalid: list[str] = [] + if not isinstance(values, dict): + return valid, ["values ist kein JSON-Objekt"] + + for raw_key, raw_value in values.items(): + try: + type_id = int(raw_key) + except (TypeError, ValueError): + invalid.append(f"ungültige ID {raw_key!r}") + continue + amount = _number_or_none(raw_value) + if amount is None: + invalid.append(f"{type_id}={raw_value!r}") + continue + valid[type_id] = amount + return valid, invalid + + +def _property_type_names(types: list[dict[str, Any]]) -> dict[int, str]: + """Tandoor-Eigenschaftstypen als ID→Name abbilden.""" + names: dict[int, str] = {} + for item in types: + try: + type_id = int(item.get("id")) + except (TypeError, ValueError): + continue + name = str(item.get("name") or "").strip() + if name: + names[type_id] = name + return names + + +def _complete_property_type_names( + properties: list[dict[str, Any]], + names: dict[int, str], +) -> list[dict[str, Any]]: + """ + Ergänzt in Tandoor-Property-Payloads den Namen des Property-Typs. + + Tandoors verschachtelter PropertyTypeSerializer verlangt beim Schreiben + neben der ID auch ``name``. GET-Antworten enthalten ihn bereits, neu von + ``merged_properties_payload`` erzeugte Einträge können jedoch nur die ID + enthalten und werden dann mit HTTP 400 abgelehnt. + """ + completed: list[dict[str, Any]] = [] + for prop in properties: + entry = dict(prop) + raw_type = entry.get("property_type") + if isinstance(raw_type, dict): + property_type = dict(raw_type) + try: + type_id = int(property_type.get("id")) + except (TypeError, ValueError): + type_id = None + if not str(property_type.get("name") or "").strip() and type_id is not None: + if name := names.get(type_id): + property_type["name"] = name + entry["property_type"] = property_type + completed.append(entry) + return completed + + +def out(text: str = "") -> None: + print(text, flush=True) + + +def data_dir() -> Path: + configured = os.environ.get("DATA_DIR", "").strip() + base = Path(configured) if configured else SUITE_ROOT / "data" / "tandoor-nutrition" + base.mkdir(parents=True, exist_ok=True) + return base + + +def stamp() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def write_json(path: Path, payload: Any) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + temporary.replace(path) + return path + + +# ---------------------------------------------------------------- Bestand + +def collect(client: TandoorClient) -> dict[str, Any]: + out("Eigenschaften werden gelesen …") + types = sorted( + client.list_objects("property-type"), + key=lambda t: (t.get("order") or 0, (t.get("name") or "").casefold()), + ) + out(f" {len(types)} Eigenschaften: " + ", ".join(t.get("name", "?") for t in types)) + + out("Zutaten werden gelesen …") + foods = client.list_objects("food") + out(f" {len(foods)} Zutaten") + return {"types": types, "foods": foods} + + +def analyse(types: list[dict[str, Any]], foods: list[dict[str, Any]]) -> dict[str, Any]: + type_ids = [t["id"] for t in types] + rows: list[dict[str, Any]] = [] + + for food in foods: + values = food_property_map(food) + missing = [tid for tid in type_ids if values.get(tid) is None] + rows.append({ + "id": food.get("id"), + "name": food.get("name") or "", + "plural_name": food.get("plural_name") or "", + "numrecipe": food.get("numrecipe") or 0, + "base_amount": food.get("properties_food_amount"), + "base_unit": (food.get("properties_food_unit") or {}).get("name") + if isinstance(food.get("properties_food_unit"), dict) else None, + "values": {str(tid): values.get(tid) for tid in type_ids}, + "missing": [str(tid) for tid in missing], + "complete": not missing, + }) + + rows.sort(key=lambda r: (r["complete"], -r["numrecipe"], r["name"].casefold())) + + per_type = [] + for t in types: + have = sum(1 for r in rows if r["values"].get(str(t["id"])) is not None) + per_type.append({ + "id": t["id"], + "name": t.get("name") or "", + "unit": t.get("unit") or "", + "have": have, + "missing": len(rows) - have, + "percent": round(have / len(rows) * 100, 1) if rows else 0.0, + }) + + complete = sum(1 for r in rows if r["complete"]) + incomplete_used = [r for r in rows if not r["complete"] and r["numrecipe"] > 0] + no_base = [r for r in rows if not r["complete"] and not r["base_amount"]] + + return { + "created_at": datetime.now(timezone.utc).isoformat(), + "types": [{"id": t["id"], "name": t.get("name"), "unit": t.get("unit")} for t in types], + "summary": { + "foods": len(rows), + "complete": complete, + "incomplete": len(rows) - complete, + "incomplete_used_in_recipes": len(incomplete_used), + "unused": sum(1 for r in rows if r["numrecipe"] == 0), + "percent_complete": round(complete / len(rows) * 100, 1) if rows else 0.0, + "without_base_amount": len(no_base), + }, + "per_type": per_type, + "foods": rows, + } + + +def write_report(report: dict[str, Any], target: Path) -> None: + write_json(target / "bericht.json", report) + + names = {str(t["id"]): t["name"] for t in report["types"]} + with (target / "luecken.csv").open("w", encoding="utf-8", newline="") as handle: + writer = csv.writer(handle, delimiter=";") + writer.writerow(["id", "Zutat", "Rezepte", "Basis"] + + [f"{t['name']} ({t['unit']})" for t in report["types"]] + ["fehlt"]) + for row in report["foods"]: + base = (f"{row['base_amount']:g} {row['base_unit']}" + if row["base_amount"] and row["base_unit"] else "") + writer.writerow( + [row["id"], row["name"], row["numrecipe"], base] + + [("" if row["values"][str(t["id"])] is None else row["values"][str(t["id"])]) + for t in report["types"]] + + [", ".join(names[m] for m in row["missing"])] + ) + + +def print_report(report: dict[str, Any]) -> None: + s = report["summary"] + out() + out("─" * 62) + out(f" Zutaten gesamt {s['foods']}") + out(f" davon vollständig {s['complete']} ({s['percent_complete']} %)") + out(f" davon unvollständig {s['incomplete']}") + out(f" … und in Rezepten benutzt {s['incomplete_used_in_recipes']} ← die tun weh") + out(f" ohne Bezugsmenge {s['without_base_amount']}") + out("─" * 62) + out() + out(" Je Eigenschaft:") + for t in report["per_type"]: + bar = "█" * round(t["percent"] / 5) + "·" * (20 - round(t["percent"] / 5)) + out(f" {t['name']:<16} {bar} {t['percent']:5.1f} % fehlt bei {t['missing']}") + out() + worst = [r for r in report["foods"] if not r["complete"]][:12] + if worst: + out(" Größte Lücken zuerst (nach Rezeptzahl):") + for row in worst: + fehlt = ", ".join( + next(t["name"] for t in report["types"] if str(t["id"]) == m) + for m in row["missing"] + ) + out(f" {row['numrecipe']:>3} Rezepte · {row['name']:<28} fehlt: {fehlt}") + + +# ------------------------------------------------------------- Vorschläge + +def _parse_json(text: str) -> dict[str, Any]: + """Antworttext zu einem Dict machen, auch wenn das Modell schludert.""" + text = (text or "").strip() + if text.startswith("```"): + text = text.strip("`") + if text[:4].lower() == "json": + text = text[4:] + text = text.strip() + data = json.loads(text) + # Manche Modelle verpacken die Antwort, z. B. {"lebensmittel": {…}}. + # Nur auspacken, wenn der einzige Wert selbst eine Tabelle von Tabellen ist. + if isinstance(data, dict) and len(data) == 1: + (only,) = data.values() + if isinstance(only, dict) and only and all(isinstance(v, dict) for v in only.values()): + return only + return data + + +# Merkt sich, welche Aufruf-Variante das Modell akzeptiert, damit nicht jede der +# vielen Anfragen erneut durchprobiert wird. +_CHAT_VARIANT: dict[str, Any] | None = None + + +def _chat_completion(client, model: str, messages: list[dict[str, str]]): + """ + Chat-Abfrage mit sanftem Rückfall. + + Neuere Modelle (o-Reihe, GPT-5-Reihe) lehnen `temperature` ungleich dem + Standard und teils `response_format` ab. Statt daran zu scheitern, wird von + der genausten zur schlichtesten Variante durchprobiert — aber nur bei + Parameter-Fehlern. Echte Fehler (falsches Modell, Auth, Netz) werden sofort + durchgereicht, damit man sie sieht. + """ + global _CHAT_VARIANT + varianten = [ + {"response_format": {"type": "json_object"}, "temperature": 0}, + {"response_format": {"type": "json_object"}}, + {"temperature": 0}, + {}, + ] + if _CHAT_VARIANT is not None: + varianten = [_CHAT_VARIANT] + + letzter = None + for extra in varianten: + try: + antwort = client.chat.completions.create(model=model, messages=messages, **extra) + _CHAT_VARIANT = extra + return antwort + except Exception as exc: # noqa: BLE001 + letzter = exc + text = str(exc).lower() + parameterfehler = any(w in text for w in ( + "temperature", "response_format", "unsupported", "not supported", + "unknown_parameter", "invalid_request", "unexpected keyword", + )) + if not parameterfehler: + raise + raise letzter + + +def ask_openai( + foods: list[dict[str, Any]], + types: list[dict[str, Any]], + model: str, +) -> dict[str, dict[str, float | None]]: + from openai import OpenAI + + client = OpenAI() + liste = "\n".join(f"- {f['name']}" for f in foods) + eigenschaften = "\n".join(f"- {t['name']} (Einheit: {t['unit'] or '?'})" for t in types) + + system = ( + "Du bist Ernährungswissenschaftler und lieferst Nährwerte für " + "Lebensmittel. Antworte ausschließlich mit JSON, ohne Fließtext und " + "ohne Markdown." + ) + user = f"""Gib für jedes Lebensmittel die Nährwerte **je 100 g** an (bei +Flüssigkeiten je 100 ml). Beziehe dich auf das rohe, unverarbeitete +Lebensmittel im handelsüblichen Zustand. + +Lebensmittel: +{liste} + +Benötigte Werte je Lebensmittel: +{eigenschaften} + +Regeln: +- Zahlen als Dezimalzahl mit Punkt, ohne Einheit. +- Kalorien in kcal je 100 g. +- Wenn ein Wert sachlich null ist (z. B. Fett in Salz), gib 0 an. +- Nur wenn du einen Wert wirklich nicht sinnvoll angeben kannst: null. +- Für Gewürze und Kräuter gilt derselbe 100-g-Bezug, auch wenn nie 100 g + verwendet werden. + +Antworte als JSON-Objekt mit genau dieser Form: +{{"Lebensmittelname": {{"Eigenschaftsname": Zahl oder null}}}} +Benutze exakt die oben genannten Namen als Schlüssel.""" + + response = _chat_completion( + client, model, + [{"role": "system", "content": system}, + {"role": "user", "content": user}], + ) + text = response.choices[0].message.content or "{}" + return _parse_json(text) + + +def probe(args: argparse.Namespace) -> int: + """Eine einzelne Testabfrage — zeigt den echten Fehler bzw. Rohtext.""" + demo_foods = [{"name": "Apfel"}, {"name": "Kochschinken"}] + demo_types = [{"name": "Kalorien", "unit": "kcal"}, {"name": "Proteine", "unit": "g"}] + out(f"Modell: {args.model}") + out("Sende eine einzelne Testabfrage an OpenAI …") + out() + try: + ergebnis = ask_openai(demo_foods, demo_types, args.model) + except Exception as exc: # noqa: BLE001 + import traceback + out("FEHLGESCHLAGEN. Das ist der vollständige Fehler:") + out() + out(traceback.format_exc().strip()) + out() + low = str(exc).lower() + if "model" in low and ("not" in low or "exist" in low or "unknown" in low): + out("→ Das Modell scheint nicht zu existieren oder nicht freigeschaltet zu sein.") + out(" In den Einstellungen ein anderes Modell setzen (OPENAI_MODEL).") + elif "api key" in low or "authentication" in low or "401" in low: + out("→ Der API-Schlüssel wird nicht akzeptiert. OPENAI_API_KEY prüfen.") + elif "quota" in low or "insufficient" in low or "429" in low: + out("→ Kontingent erschöpft oder zu viele Anfragen. Bei OpenAI das Guthaben prüfen.") + return 1 + + out("Erfolg. Die API antwortet und die Antwort ließ sich lesen:") + out() + out(json.dumps(ergebnis, ensure_ascii=False, indent=2)) + out() + out(f"Verwendete Aufruf-Variante: {_CHAT_VARIANT}") + out("Damit sollte „Vorschlagen“ funktionieren.") + return 0 + + +def propose( + client: TandoorClient, + args: argparse.Namespace, + bestand: dict[str, Any], +) -> Path: + types = bestand["types"] + foods = bestand["foods"] + by_id = {f["id"]: f for f in foods} + + if args.foods: + wanted = [int(x) for x in args.foods.split(",") if x.strip()] + unknown = [i for i in wanted if i not in by_id] + if unknown: + raise SystemExit(f"Unbekannte Zutaten-IDs: {unknown}") + selected = [by_id[i] for i in wanted] + else: + selected = foods + + type_filter = ([int(x) for x in args.properties.split(",") if x.strip()] + if args.properties else [t["id"] for t in types]) + active_types = [t for t in types if t["id"] in type_filter] + if not active_types: + raise SystemExit("Keine gültige Eigenschaft ausgewählt.") + + todo = [] + for food in selected: + values = food_property_map(food) + missing = [t for t in active_types + if args.overwrite or values.get(t["id"]) is None] + if missing: + todo.append((food, missing)) + + if not todo: + out("Nichts zu tun: Für die Auswahl ist bereits alles gefüllt.") + raise SystemExit(0) + + if args.limit: + todo = todo[:args.limit] + + out(f"Modell: {args.model}") + out(f"{len(todo)} Zutaten mit Lücken, {len(active_types)} Eigenschaften.") + out(f"Das sind etwa {(len(todo) + BATCH - 1) // BATCH} Anfragen an OpenAI.") + out() + + proposals: list[dict[str, Any]] = [] + failed: list[str] = [] + unresolved: list[dict[str, Any]] = [] + first_error: str | None = None + + for start in range(0, len(todo), BATCH): + chunk = todo[start:start + BATCH] + out(f" Anfrage {start // BATCH + 1}: {', '.join(f['name'] for f, _ in chunk)}") + try: + answer = ask_openai([f for f, _ in chunk], active_types, args.model) + except Exception as exc: + out(f" Fehler: {exc}") + if first_error is None: + first_error = str(exc) + failed.extend(f["name"] for f, _ in chunk) + continue + + lookup = {str(k).strip().casefold(): v for k, v in answer.items() + if isinstance(v, dict)} + for food, missing in chunk: + got = lookup.get(food["name"].strip().casefold()) + if got is None: + out(f" ohne Antwort: {food['name']}") + failed.append(food["name"]) + continue + werte: dict[str, float] = {} + offene_felder: list[str] = [] + for t in missing: + raw = got.get(t["name"], _MISSING) + number = None if raw is _MISSING else _number_or_none(raw) + if number is not None: + # Auch number == 0 wird als vorhandener Wert gespeichert. + werte[str(t["id"])] = round(number, 2) + else: + offene_felder.append(t["name"]) + if werte: + proposals.append({ + "food_id": food["id"], + "food_name": food["name"], + "numrecipe": food.get("numrecipe") or 0, + "current": {str(k): v for k, v in food_property_map(food).items()}, + "values": werte, + "accept": True, + }) + out(f" {food['name']}: " + ", ".join( + f"{t['name']} {werte[str(t['id'])]:g}{t['unit'] or ''}" + for t in missing if str(t["id"]) in werte)) + if offene_felder: + unresolved.append({ + "food_id": food["id"], + "food_name": food["name"], + "properties": offene_felder, + }) + out(f" weiterhin offen: {', '.join(offene_felder)}") + else: + failed.append(food["name"]) + if offene_felder: + unresolved.append({ + "food_id": food["id"], + "food_name": food["name"], + "properties": offene_felder, + }) + + target = data_dir() / "vorschlaege" / f"{stamp()}.json" + write_json(target, { + "created_at": datetime.now(timezone.utc).isoformat(), + "model": args.model, + "base_amount": args.base_amount, + "base_unit": args.base_unit, + "types": [{"id": t["id"], "name": t["name"], "unit": t.get("unit")} for t in active_types], + "proposals": proposals, + "failed": failed, + "unresolved": unresolved, + "error": first_error, + }) + out() + out(f"{len(proposals)} Vorschläge geschrieben, {len(failed)} ohne Ergebnis.") + if unresolved: + out(f"{len(unresolved)} Zutaten enthalten noch einzelne offene Werte.") + out(f"Vorschlagsdatei: {target}") + out() + if not proposals and failed: + out("Kein einziger Vorschlag — das deutet auf ein grundsätzliches Problem hin,") + out("nicht auf einzelne Zutaten.") + if first_error: + out(f"Erster Fehler der API: {first_error}") + else: + out("Die API hat geantwortet, aber die Namen passten nicht zusammen.") + out("Zur genauen Ursache: `nutrition.py probe` macht eine einzelne") + out("Testabfrage und zeigt den vollständigen Fehler.") + out() + out("Die Werte sind Schätzungen des Sprachmodells. Vor dem Übernehmen") + out("bitte in der Oberfläche durchsehen.") + return target + + +# ------------------------------------------------------------ Übernehmen + +def apply_proposal(client: TandoorClient, args: argparse.Namespace) -> int: + file = Path(args.vorschlag).expanduser().resolve() + if not file.is_file(): + raise SystemExit(f"Vorschlagsdatei nicht gefunden: {file}") + payload = json.loads(file.read_text(encoding="utf-8")) + proposals = [p for p in payload.get("proposals", []) if p.get("accept", True)] + if not proposals: + out("In der Vorschlagsdatei ist nichts angehakt.") + return 0 + + base_amount = payload.get("base_amount") or args.base_amount + base_unit_name = payload.get("base_unit") or args.base_unit + + # Die Vorschlagsdatei enthält bereits die Namen der bearbeiteten Typen. + # Beim echten Schreiben ergänzen wir die vollständige, aktuelle Liste aus + # Tandoor, damit auch bereits vorhandene Eigenschaften sicher abgedeckt sind. + property_type_names = _property_type_names(payload.get("types") or []) + if args.apply: + property_type_names.update( + _property_type_names(client.list_objects("property-type")) + ) + + out(f"Modus: {'ÜBERNEHMEN' if args.apply else 'TROCKENÜBUNG'}") + out(f"Tandoor: {client.base_url}") + out(f"Zutaten: {len(proposals)}") + out(f"Bezug: {base_amount:g} {base_unit_name}") + out() + + base_unit = None + if args.apply: + units = client.list_objects("unit") + base_unit = next( + (u for u in units + if (u.get("name") or "").strip().casefold() == base_unit_name.casefold()), + None, + ) + if base_unit is None: + out(f"Einheit „{base_unit_name}“ gibt es in Tandoor nicht.") + out("Bitte anlegen oder eine andere Bezugseinheit wählen.") + return 2 + + run = data_dir() / "laeufe" / stamp() + run.mkdir(parents=True, exist_ok=True) + manifest: dict[str, Any] = { + "created_at": datetime.now(timezone.utc).isoformat(), + "plugin": "tandoor-nutrition", + "label": f"Nährwerte übernehmen ({file.stem})", + "tandoor": client.base_url, + "mode": "apply" if args.apply else "dry", + "proposal": file.name, + "steps": [], + } + written, skipped, errors = 0, 0, 0 + + for index, item in enumerate(proposals, start=1): + food_id = item["food_id"] + prefix = f"[{index}/{len(proposals)}] {item['food_name']} [{food_id}]" + try: + food = client.get_json(f"api/food/{food_id}/") + except TandoorError as exc: + out(f"{prefix}: nicht lesbar — {exc}") + errors += 1 + if not args.continue_on_error: + return 1 + continue + + updates, invalid_values = _normalise_proposal_values(item.get("values")) + if invalid_values: + out(f"{prefix}: ungültige Vorschlagswerte ignoriert: {', '.join(invalid_values)}") + if not args.overwrite: + vorhanden = food_property_map(food) + updates = {k: v for k, v in updates.items() if vorhanden.get(k) is None} + if not updates: + out(f"{prefix}: inzwischen schon gefüllt, übersprungen") + skipped += 1 + continue + + merged_properties = merged_properties_payload(food, updates) + body: dict[str, Any] = { + "properties": _complete_property_type_names( + merged_properties, property_type_names + ) + } + if food.get("properties_food_amount") is None: + body["properties_food_amount"] = base_amount + if not food.get("properties_food_unit") and base_unit: + body["properties_food_unit"] = {"id": base_unit["id"], "name": base_unit["name"]} + + werte = ", ".join(f"{k}={v:g}" for k, v in updates.items()) + if not args.apply: + out(f"{prefix}: würde setzen → {werte}") + written += 1 + continue + + (run / f"vorher-{food_id:05d}.json").write_text( + json.dumps(food, ensure_ascii=False, indent=2), encoding="utf-8" + ) + # Der Schritt wird VOR dem Schreiben vermerkt. Bricht der Prozess + # mittendrin ab, weiß das Rückspielen trotzdem, was angefasst wurde. + step: dict[str, Any] = { + "n": index, + "action": "properties", + "food_id": food_id, + "food_name": item["food_name"], + "before": { + "properties": food.get("properties") or [], + "properties_food_amount": food.get("properties_food_amount"), + "properties_food_unit": food.get("properties_food_unit"), + }, + "written": {str(k): v for k, v in updates.items()}, + "restore_level": "voll", + "status": "angefangen", + } + manifest["steps"].append(step) + backups.write_manifest(run, manifest) + + try: + result = client.patch_json(f"api/food/{food_id}/", body) + except TandoorError as exc: + out(f"{prefix}: FEHLER — {exc}") + step["status"] = "fehler" + step["error"] = str(exc) + backups.write_manifest(run, manifest) + errors += 1 + if not args.continue_on_error: + out("Abbruch. Die bereits geschriebenen Zutaten bleiben gesetzt;") + out(f"die Sicherungen liegen in {run}") + return 1 + continue + + step["status"] = "done" + backups.write_manifest(run, manifest) + (run / f"nachher-{food_id:05d}.json").write_text( + json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8" + ) + # Nicht nur der PATCH-Antwort vertrauen: frisch aus Tandoor lesen und + # ausdrücklich prüfen, ob auch 0-Werte wirklich gespeichert wurden. + try: + verified_food = client.get_json(f"api/food/{food_id}/") + except TandoorError as exc: + out(f"{prefix}: geschrieben, aber Rückprüfung nicht möglich — {exc}") + verified_food = result + + nachher = food_property_map(verified_food) + fehlend = [k for k in updates if nachher.get(k) is None] + abweichend = [ + k for k, expected in updates.items() + if nachher.get(k) is not None + and abs(nachher[k] - expected) > 1e-6 + ] + if fehlend or abweichend: + details = [] + if fehlend: + details.append(f"fehlend: {fehlend}") + if abweichend: + details.append( + "abweichend: " + ", ".join( + f"{k} erwartet {updates[k]:g}, erhalten {nachher.get(k)!r}" + for k in abweichend + ) + ) + out(f"{prefix}: geschrieben, aber Rückprüfung fehlgeschlagen ({'; '.join(details)})") + errors += 1 + else: + out(f"{prefix}: ✓ gesetzt und rückgeprüft → {werte}") + written += 1 + + out() + out("─" * 62) + verb = "gesetzt" if args.apply else "würden gesetzt" + out(f" {written} {verb} · {skipped} übersprungen · {errors} Fehler") + if args.apply: + out(f" Sicherungen: {run}") + out(f" Zurückspielen: Reiter „Sicherungen“ oder") + out(f" nutrition.py zurueck --lauf {run.name} --apply") + else: + out(" Nichts verändert. Mit --apply wird geschrieben.") + out("─" * 62) + return 1 if errors else 0 + + +# ---------------------------------------------------------- Zurückspielen + +def restore(client: TandoorClient, args: argparse.Namespace) -> int: + runs_dir = data_dir() / "laeufe" + try: + run = backups.resolve_run(runs_dir, args.lauf) + except FileNotFoundError as exc: + out(str(exc)) + return 2 + manifest = backups.read_manifest(run) or {} + steps = [s for s in manifest.get("steps", []) if s.get("status") == "done"] + if not steps: + out("In diesem Lauf wurde nichts geschrieben — nichts zurückzuspielen.") + return 0 + + out(f"Modus: {'ZURÜCKSPIELEN' if args.apply else 'TROCKENÜBUNG'}") + out(f"Lauf: {run.name} ({manifest.get('label')})") + out(f" vom {manifest.get('created_at')}") + out(f"Tandoor: {client.base_url}") + if manifest.get("tandoor") and manifest["tandoor"] != client.base_url: + out() + out(f"ACHTUNG: Der Lauf ging gegen {manifest['tandoor']},") + out(f" jetzt eingestellt ist {client.base_url}.") + if not args.force: + out(" Abbruch. Mit --force trotzdem.") + return 2 + verlauf = backups.restore_history(run) + if verlauf and not args.force: + out() + out(f"Dieser Lauf wurde bereits am {verlauf[-1]['at']} zurückgespielt.") + out("Mit --force noch einmal.") + return 2 + out(f"{len(steps)} Zutaten") + out() + + property_type_names: dict[int, str] = {} + if args.apply: + property_type_names = _property_type_names( + client.list_objects("property-type") + ) + + zurueck, uebersprungen, fehler = 0, 0, 0 + for step in steps: + food_id = step["food_id"] + prefix = f"[{step['n']}] {step['food_name']} [{food_id}]" + try: + jetzt = client.get_json(f"api/food/{food_id}/") + except TandoorError as exc: + out(f"{prefix}: nicht lesbar — {exc}") + fehler += 1 + continue + + before = step["before"] + # Schutz: Wurde die Zutat seit dem Lauf von Hand angefasst? + aktuell = food_property_map(jetzt) + geschrieben = {int(k): v for k, v in step["written"].items()} + abweichung = [ + k for k, v in geschrieben.items() + if aktuell.get(k) is not None and abs(aktuell[k] - float(v)) > 1e-6 + ] + if abweichung and not args.force: + out(f"{prefix}: übersprungen — Werte wurden zwischenzeitlich geändert. " + f"Mit --force trotzdem.") + uebersprungen += 1 + continue + + # Die alte properties-Liste eins zu eins zurückschreiben. Da Tandoor die + # Liste komplett ersetzt, verschwindet damit genau das, was dieser Lauf + # hinzugefügt hat. + alt = [] + for prop in before.get("properties") or []: + raw_type = prop.get("property_type") or {} + ptype = raw_type.get("id") + if not isinstance(ptype, int): + continue + type_payload: dict[str, Any] = {"id": ptype} + if name := str(raw_type.get("name") or "").strip(): + type_payload["name"] = name + eintrag = {"property_amount": prop.get("property_amount"), + "property_type": type_payload} + if isinstance(prop.get("id"), int): + eintrag["id"] = prop["id"] + alt.append(eintrag) + + body: dict[str, Any] = { + "properties": _complete_property_type_names(alt, property_type_names) + } + if before.get("properties_food_amount") is None: + body["properties_food_amount"] = None + if before.get("properties_food_unit") is None: + body["properties_food_unit"] = None + + namen = ", ".join(str(k) for k in geschrieben) + if not args.apply: + out(f"{prefix}: würde zurücksetzen auf {len(alt)} Eigenschaften " + f"(entfernt: {namen})") + zurueck += 1 + continue + + try: + ergebnis = client.patch_json(f"api/food/{food_id}/", body) + except TandoorError as exc: + out(f"{prefix}: FEHLER — {exc}") + fehler += 1 + continue + + nachher = food_property_map(ergebnis) + rest = [k for k in geschrieben if nachher.get(k) is not None + and food_property_map({"properties": before.get("properties") or []}).get(k) is None] + if rest: + out(f"{prefix}: zurückgeschrieben, aber {rest} sind noch gesetzt") + fehler += 1 + else: + out(f"{prefix}: ✓ auf den Stand vor dem Lauf zurückgesetzt") + zurueck += 1 + + out() + out("─" * 62) + verb = "zurückgespielt" if args.apply else "würden zurückgespielt" + out(f" {zurueck} {verb} · {uebersprungen} übersprungen · {fehler} Fehler") + if args.apply: + backups.mark_restored(run, {"restored": zurueck, "skipped": uebersprungen, + "errors": fehler}) + out(" Der Lauf ist als zurückgespielt vermerkt.") + else: + out(" Nichts verändert. Mit --apply wird zurückgeschrieben.") + out("─" * 62) + return 1 if fehler else 0 + + +# ---------------------------------------------------------------- Selbsttest + +def selftest() -> int: + """Prüft lokal, dass 0 weder beim Lesen noch beim Payload-Bau verloren geht.""" + food = { + "properties": [ + { + "id": 11, + "property_amount": 0, + "property_type": {"id": 1, "name": "Fett"}, + }, + { + "id": 12, + "property_amount": "0.00", + "property_type": {"id": 2, "name": "Zucker"}, + }, + { + "id": 13, + "property_amount": 4.2, + "property_type": {"id": 3, "name": "Protein"}, + }, + ] + } + + values = food_property_map(food) + assert values == {1: 0.0, 2: 0.0, 3: 4.2}, values + + payload = merged_properties_payload(food, {1: 0, 2: "0", 4: 0.0}) + payload_values = food_property_map({"properties": payload}) + assert payload_values == {1: 0.0, 2: 0.0, 3: 4.2, 4: 0.0}, payload_values + + proposal_values, invalid = _normalise_proposal_values({"1": 0, "2": "0.00"}) + assert proposal_values == {1: 0.0, 2: 0.0}, proposal_values + assert invalid == [], invalid + + out("Selbsttest erfolgreich: numerische 0 bleibt in allen Verarbeitungsschritten erhalten.") + return 0 + + +# ----------------------------------------------------------------- Aufruf + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.split("\n")[1]) + parser.add_argument("--base-url", default=None) + parser.add_argument("--token", default=None) + parser.add_argument("--auth-scheme", default=None) + parser.add_argument("--timeout", type=float, default=None) + parser.add_argument("--insecure", action="store_true") + + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("pruefen", help="Bericht über fehlende Nährwerte") + p.add_argument("--json", action="store_true", help="Bericht auf stdout ausgeben") + + p = sub.add_parser("vorschlagen", help="Fehlende Werte von OpenAI schätzen lassen") + p.add_argument("--foods", default="", help="IDs, kommagetrennt. Leer = alle") + p.add_argument("--properties", default="", help="Eigenschafts-IDs. Leer = alle") + p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5")) + p.add_argument("--limit", type=int, default=0, help="höchstens so viele Zutaten") + p.add_argument("--overwrite", action="store_true", help="auch vorhandene Werte neu schätzen") + p.add_argument("--base-amount", type=float, default=100.0) + p.add_argument("--base-unit", default="g") + + p = sub.add_parser("probe", help="Eine einzelne Testabfrage an OpenAI") + p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5")) + + sub.add_parser("selftest", help="Nullwert-Behandlung lokal prüfen") + + p = sub.add_parser("zurueck", help="Einen Lauf zurückspielen") + p.add_argument("--lauf", required=True, help="Ordnername unter laeufe/") + p.add_argument("--apply", action="store_true", help="wirklich zurückschreiben") + p.add_argument("--force", action="store_true", + help="auch bei zwischenzeitlichen Änderungen oder anderem Tandoor") + + p = sub.add_parser("uebernehmen", help="Vorschlagsdatei nach Tandoor schreiben") + p.add_argument("--vorschlag", required=True) + p.add_argument("--apply", action="store_true", help="wirklich schreiben") + p.add_argument("--overwrite", action="store_true") + p.add_argument("--continue-on-error", action="store_true") + p.add_argument("--base-amount", type=float, default=100.0) + p.add_argument("--base-unit", default="g") + return parser + + +def main() -> int: + args = build_parser().parse_args() + + if args.command == "selftest": + return selftest() + + # Der Probe-Befehl braucht kein Tandoor, nur OpenAI. + if args.command == "probe": + if not os.environ.get("OPENAI_API_KEY"): + out("OPENAI_API_KEY fehlt — siehe Einstellungen.") + return 2 + return probe(args) + + try: + client = TandoorClient.from_env( + base_url=args.base_url, + token=args.token, + auth_scheme=args.auth_scheme, + timeout=args.timeout, + verify=False if args.insecure else None, + ) + except TandoorError as exc: + out(f"Tandoor-Zugang fehlt: {exc}") + return 2 + + try: + if args.command == "pruefen": + bestand = collect(client) + report = analyse(bestand["types"], bestand["foods"]) + target = data_dir() / "bericht" + write_report(report, target) + print_report(report) + out() + out(f"Bericht: {target / 'bericht.json'}") + out(f"Tabelle: {target / 'luecken.csv'}") + if args.json: + out(json.dumps(report, ensure_ascii=False)) + return 0 + + if args.command == "vorschlagen": + if not os.environ.get("OPENAI_API_KEY"): + out("OPENAI_API_KEY fehlt — siehe Einstellungen.") + return 2 + bestand = collect(client) + propose(client, args, bestand) + return 0 + + if args.command == "uebernehmen": + return apply_proposal(client, args) + + if args.command == "zurueck": + return restore(client, args) + + except TandoorError as exc: + out(f"Tandoor meldet: {exc}") + return 1 + except KeyboardInterrupt: + out("Abgebrochen.") + return 130 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/werkzeuge-wandler/TOOL-README.md b/plugins/werkzeuge-wandler/TOOL-README.md new file mode 100644 index 0000000..8352a79 --- /dev/null +++ b/plugins/werkzeuge-wandler/TOOL-README.md @@ -0,0 +1,82 @@ +# Wandler + +Einheiten, Formate, Farben und Zeit — in einer Zeile. + +Dieses Werkzeug fällt bewusst aus der Reihe der übrigen Plugins: Es hat keinen +Job-Runner, keine Sicherungen, keine KI und keinen Tandoor-Zugriff. Es verändert +nichts und rechnet **vollständig im Browser**. Dadurch ist das Ergebnis sofort +da und funktioniert auch offline oder wenn Tandoor gerade streikt. + +## Die Zeile oben + +Einfach tippen, was gemeint ist — die Erkennung läuft deterministisch (keine KI), +also immer gleich und ohne Wartezeit: + +``` +2 cups Mehl in g → 251 g +180 C in F → 356 °F +5 km in mi → 3,1069 mi +2,5 TB in GiB → 2.328,31 GiB +1 1/2 EL in ml → 22,5 ml +#3b82f6 → rgb(59, 130, 246), hsl(217, 91%, 60%) +1699999999 → Dienstag, 14. November 2023 +0 3 * * 1 → montags um 03:00 Uhr +0xff → 255 +5 kg → Übersicht aller Gewichtseinheiten +``` + +Als Trennwort funktionieren `in`, `nach`, `to`, `als`, `zu`, `→` und `>`. +Mit **Eingabetaste** wird eine Umrechnung oben angeheftet; angeheftete und +zuletzt benutzte Umrechnungen bleiben im Browser gespeichert (nur lokal, es +verlässt nichts das Gerät). Ein Klick auf eine Ergebniszeile kopiert sie. + +## Zahlen schreiben + +* Komma und Punkt werden beide als Dezimaltrennzeichen verstanden: `2,5` = `2.5`. +* Tausendertrennung wird erkannt, wenn beide Zeichen vorkommen (`1.234,56`) oder + mehrere gleiche (`1.234.567`). +* Brüche gehen: `3/4`, `1 1/2`, `1½`. +* **Grenzfall:** `1.234` allein ist zweideutig und wird als 1,234 gelesen. Wer + 1234 meint, schreibt es ohne Punkt. + +## Reiter + +**Küche** — Zutat umrechnen (Volumen ↔ Gewicht mit Dichte je Zutat), Ofen +(°C / °F / Umluft / Gasstufe), Löffel- und Tassenmaße, Portionen hochrechnen. + +**Maße** — Länge, Gewicht, Volumen, Fläche, Zeit, Geschwindigkeit, Energie, +Druck, Temperatur. + +**Technik** — Datenmengen (MB und MiB sauber getrennt), Zahlensysteme, Farben +mit Farbwähler. + +**Formate** — Base64, URL-Kodierung, Slug, Groß-/Kleinschreibung, camelCase, +snake_case, kebab-case, Leerraum säubern, Zeichen zählen, JSON formatieren, +JSON ↔ YAML, JSON ↔ CSV. + +**Zeit** — Zeitstempel, Zeitzonen, Datumsdifferenz, Kalenderwoche, Cron im +Klartext. + +## Warum die Küchenwerte so aussehen + +Volumen und Gewicht lassen sich nur über die **Dichte** ineinander umrechnen, +und die hängt an der Zutat: 1 Cup Mehl wiegt rund 125 g, 1 Cup Zucker rund +201 g. Die hinterlegte Tabelle deckt die üblichen Zutaten ab; ist eine Zutat +unbekannt, wird mit der Dichte von Wasser gerechnet **und das deutlich +angezeigt**. Küchenergebnisse werden bewusst grob gerundet — „250,7835 g Mehl“ +wiegt niemand ab. + +Ein US-Cup sind 236,6 ml, nicht 250 ml. „Pfund“ wird als 500 g gelesen (deutsche +Bedeutung), `lb` als 453,59 g. + +## Bekannte Grenzen + +* **YAML → JSON** liest nur den einfachen Teil der Sprache: verschachtelte + Zuordnungen, Listen und Skalare. Keine Anker, keine mehrzeiligen Blöcke, keine + Fluss-Syntax. Die Richtung JSON → YAML ist vollständig. +* **Gasstufen** folgen der britischen Skala (Gas Mark); deutsche Geräte können + abweichen. +* **Währungen** fehlen bewusst — die bräuchten einen Live-Kurs, und ein Wandler, + der still mit vorgestrigen Kursen rechnet, wäre schlimmer als keiner. +* Zeitzonen kommen aus dem Browser (`Intl`), es ist keine eigene Zeitzonen- + Datenbank eingebaut. diff --git a/plugins/werkzeuge-wandler/backend.py b/plugins/werkzeuge-wandler/backend.py new file mode 100644 index 0000000..e52f778 --- /dev/null +++ b/plugins/werkzeuge-wandler/backend.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +""" +Plugin-Adapter für den „Wandler“. + +Bewusst schlank: Dieses Werkzeug rechnet vollständig im Browser. Es gibt keinen +Job-Runner, keine Sicherungen und keine KI — es verändert nichts und braucht +keinen Serverlauf. Das Backend liefert nur die Seite aus. + +Vorteil: sofort da, funktioniert auch offline und wenn Tandoor gerade streikt. +""" +from __future__ import annotations + +from typing import Any + +from fastapi import FastAPI +from fastapi.responses import FileResponse + + +def create_app(ctx): + app = FastAPI(title=ctx.meta.name, docs_url="/api/docs", redoc_url=None) + + @app.get("/", include_in_schema=False) + def index(): + return FileResponse(ctx.path("static", "index.html")) + + @app.get("/api/state") + def state() -> dict[str, Any]: + # Nur damit die Oberfläche eine Lebenszeichen-Abfrage hat. + return {"ok": True, "version": ctx.meta.version, "offline": True} + + return app diff --git a/plugins/werkzeuge-wandler/plugin.json b/plugins/werkzeuge-wandler/plugin.json new file mode 100644 index 0000000..928b912 --- /dev/null +++ b/plugins/werkzeuge-wandler/plugin.json @@ -0,0 +1,18 @@ +{ + "id": "werkzeuge-wandler", + "name": "Wandler", + "summary": "Einheiten, Formate, Farben, Zeit — alles in einer Zeile", + "description": "Ein Eingabefeld für alles: „2 cups Mehl in g“, „180 C in F“, „#3b82f6“, „1699999999“. Dazu Rechner für Küche, Maße, Technik, Formate und Zeit. Rechnet vollständig im Browser — kein Server, keine KI, keine Wartezeit.", + "icon": "🔀", + "category": "Werkzeuge", + "version": "1.0.0", + "entrypoint": "backend:create_app", + "order": 60, + "requires": [], + "features": [ + "Eine Zeile für alles — erkennt selbst, was gemeint ist", + "Küchenmaße mit Dichte je Zutat (1 Cup Mehl ≠ 1 Cup Zucker)", + "Läuft ohne Server und ohne Internet" + ], + "docs": "TOOL-README.md" +} diff --git a/plugins/werkzeuge-wandler/static/index.html b/plugins/werkzeuge-wandler/static/index.html new file mode 100644 index 0000000..005445b --- /dev/null +++ b/plugins/werkzeuge-wandler/static/index.html @@ -0,0 +1,1490 @@ + + + + + +Wandler + + + + + +
+
+

🔀 Wandler

+

Einheiten, Formate, Farben und Zeit. Tipp einfach, was du meinst — + gerechnet wird sofort im Browser, ohne Server und ohne Internet.

+
+ +
+ +
+
+
+
+ +
+ + + + + +
+ + +
+
+
+

Zutat umrechnen

+

Volumen ↔ Gewicht hängt von der Zutat ab: 1 Cup Mehl wiegt + deutlich weniger als 1 Cup Zucker.

+
+ + + + +
+
+ +
+
+
+ +
+

Ofen

+
+ + +
+
+

Umluft ≈ 20 °C weniger als Ober-/Unterhitze. Gasstufen nach + britischer Skala (Gas Mark) — bei deutschen Geräten kann das abweichen.

+
+ +
+

Löffel & Tassen

+
+

EL = 15 ml, TL = 5 ml, Msp ≈ 0,5 ml, Prise ≈ 0,3 ml. + US-Cup = 236,6 ml (nicht 250 ml).

+
+ +
+

Portionen umrechnen

+
+ + +
+
+ +
+
+
+
+
+ + +
+
+
+ + + +
+
+
+
+ + +
+
+
+

Datenmengen

+
+ + +
+
+

MB (1000) und MiB (1024) werden sauber getrennt — genau da + kommen die „fehlenden“ Gigabyte auf Festplatten her.

+
+ +
+

Zahlensysteme

+
+ + +
+
+
+ +
+

Farben

+
+ + + +
+
+
+
+
+ + +
+
+
+ + + + +
+
+ +
+
+ +
+

+
+
+ + +
+
+
+

Zeitstempel

+
+ + +
+
+
+ +
+

Zeitzonen

+
+ + +
+
+
+ +
+

Datumsdifferenz

+
+ +
+
+
+ +
+

Cron im Klartext

+
+ +
+
+

Reihenfolge: Minute Stunde Tag Monat Wochentag.

+
+
+
+ +
+

Zuletzt

+
Noch nichts umgerechnet.
+
+
+ + + +