from pathlib import Path from datetime import datetime from uuid import uuid4 import json from flask import Flask, send_from_directory, request, jsonify from flask_cors import CORS from PIL import Image import ollama BASE_DIR = Path(__file__).resolve().parent PICTURES_DIR = BASE_DIR / "pictures" OBJECTS_DIR = BASE_DIR / "objects_image" SENTENCE_DIR = BASE_DIR / "sentence_object" PROMPTS_DIR = BASE_DIR / "prompts" app = Flask( __name__, template_folder=str(BASE_DIR / "templates"), static_folder=str(BASE_DIR / "static"), ) CORS(app) def read_prompt(filepath: Path, fallback: str) -> str: """ Hilfsfunktion, um Prompt-Dateien zu lesen. """ try: if filepath.exists(): return filepath.read_text(encoding="utf-8").strip() except Exception: pass return fallback.strip() @app.route("/api/images", methods=["GET"]) def list_images(): """ Gibt alle Bilder im pictures-Ordner zurück. Query-Parameter: ?mode=draw (Standard) oder ?mode=generate draw → Bilder ohne _saved-Suffix generate → Bilder mit _saved-Suffix """ mode = request.args.get("mode", "draw") PICTURES_DIR.mkdir(parents=True, exist_ok=True) if mode == "generate": image_paths = sorted( [f for f in PICTURES_DIR.iterdir() if f.is_file() and f.stem.endswith("_saved")], key=lambda p: p.stat().st_mtime, ) else: image_paths = sorted( [f for f in PICTURES_DIR.iterdir() if f.is_file() and not f.stem.endswith("_saved")], key=lambda p: p.stat().st_mtime, ) return jsonify({"images": [p.name for p in image_paths]}) REACT_BUILD_DIR = BASE_DIR / "static" / "react" def _serve_spa(): """Liefert die React SPA index.html für alle Frontend-Routen.""" return send_from_directory(REACT_BUILD_DIR, "index.html") @app.route("/") @app.route("/draw") @app.route("/generate") def serve_spa(): return _serve_spa() @app.route("/assets/") def serve_react_assets(filename: str): """Liefert JS/CSS-Assets aus dem Vite-Build.""" return send_from_directory(REACT_BUILD_DIR / "assets", filename) @app.route("/pictures/") def serve_picture(filename: str): # Statisches Ausliefern der Originalbilder return send_from_directory(PICTURES_DIR, filename) @app.route("/objects_image/") def serve_object_image(filename: str): # Ausliefern der gespeicherten Ausschnitt-Bilder return send_from_directory(OBJECTS_DIR, filename) @app.route("/api/objects", methods=["GET"]) def list_objects_for_image(): """ Gibt alle gespeicherten Objekte (Ausschnitte) zu einem Quellbild zurück. Query-Parameter: ?filename=testbild.png """ filename = request.args.get("filename") if not filename: return jsonify({"error": "Missing filename query parameter"}), 400 # Für *_saved-Bilder auch Objekte berücksichtigen, deren source_filename # noch auf den ursprünglichen Namen ohne "_saved" zeigt (Kompatibilität) base_filename = filename legacy_filename = None if base_filename.endswith(".png") or base_filename.endswith(".jpg") or base_filename.endswith(".jpeg") or base_filename.endswith(".webp"): stem = base_filename.rsplit(".", 1)[0] suffix = base_filename.rsplit(".", 1)[1] if stem.endswith("_saved"): legacy_filename = f"{stem[:-6]}.{suffix}" OBJECTS_DIR.mkdir(parents=True, exist_ok=True) objects = [] for meta_file in OBJECTS_DIR.glob("*.txt"): try: meta = json.loads(meta_file.read_text(encoding="utf-8")) except Exception: continue src_name = meta.get("source_filename") if src_name != filename and (legacy_filename is None or src_name != legacy_filename): continue objects.append( { "id": meta.get("id"), "image_file": meta.get("image_file"), "title_de": meta.get("title_de", ""), "position_de": meta.get("position_de", ""), "action_de": meta.get("action_de", ""), "condition_de": meta.get("condition_de", ""), # Von KI erzeugte Details (optional) "label_en": meta.get("label_en"), "label_de": meta.get("label_de"), "label_se": meta.get("label_se"), "color_en": meta.get("color_en"), "adjective_en": meta.get("adjective_en"), "action_verb_en": meta.get("action_verb_en"), "preposition_en": meta.get("preposition_en"), "relative_position_en": meta.get("relative_position_en"), "season_en": meta.get("season_en"), "mode": meta.get("mode"), "bbox": meta.get("bbox"), "polygon": meta.get("polygon"), "hierarchy": meta.get("hierarchy", 1), "parent_id": meta.get("parent_id"), "created_at": meta.get("created_at"), } ) # Älteste zuerst sortieren (1 = ältestes Objekt) objects.sort(key=lambda o: o.get("created_at") or "") # Laufende Nummer je Bild (1..n) nach obiger Sortierung for idx, obj in enumerate(objects, start=1): obj["index"] = idx return jsonify({"objects": objects}) @app.route("/api/object//hierarchy", methods=["POST"]) def update_object_hierarchy(obj_id: str): """ Aktualisiert die Hierarchie (1, 2 oder 3) eines gespeicherten Objekts. """ data = request.get_json(force=True, silent=True) or {} try: hierarchy = int(data.get("hierarchy")) except (TypeError, ValueError): return jsonify({"error": "Invalid hierarchy"}), 400 if hierarchy not in (1, 2, 3): return jsonify({"error": "Hierarchy must be 1, 2 or 3"}), 400 meta_path = OBJECTS_DIR / f"{obj_id}.txt" if not meta_path.exists(): return jsonify({"error": "Object not found"}), 404 try: meta = json.loads(meta_path.read_text(encoding="utf-8")) except Exception: return jsonify({"error": "Could not read meta file"}), 500 meta["hierarchy"] = hierarchy meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") return jsonify({"id": obj_id, "hierarchy": hierarchy}) @app.route("/api/object/", methods=["POST"]) def update_object_meta(obj_id: str): """ Aktualisiert Text-Metadaten (title_de, position_de, action_de, condition_de) eines gespeicherten Objekts. """ data = request.get_json(force=True, silent=True) or {} meta_path = OBJECTS_DIR / f"{obj_id}.txt" if not meta_path.exists(): return jsonify({"error": "Object not found"}), 404 try: meta = json.loads(meta_path.read_text(encoding="utf-8")) except Exception: return jsonify({"error": "Could not read meta file"}), 500 for key in ("title_de", "position_de", "action_de", "condition_de"): if key in data: meta[key] = data.get(key, "") meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") return jsonify({k: meta.get(k) for k in ("id", "title_de", "position_de", "action_de", "condition_de")}) @app.route("/api/object//parent", methods=["POST"]) def update_object_parent(obj_id: str): """ Aktualisiert die Parent-Relation eines Objekts. Erwartet JSON: { "parent_id": "" | null } """ data = request.get_json(force=True, silent=True) or {} parent_id = data.get("parent_id") meta_path = OBJECTS_DIR / f"{obj_id}.txt" if not meta_path.exists(): return jsonify({"error": "Object not found"}), 404 try: meta = json.loads(meta_path.read_text(encoding="utf-8")) except Exception: return jsonify({"error": "Could not read meta file"}), 500 # Optional: prüfen, ob parent_id existiert und zum selben Bild gehört if parent_id: parent_meta_path = OBJECTS_DIR / f"{parent_id}.txt" if not parent_meta_path.exists(): return jsonify({"error": "Parent object not found"}), 400 try: parent_meta = json.loads(parent_meta_path.read_text(encoding="utf-8")) except Exception: return jsonify({"error": "Could not read parent meta file"}), 500 if parent_meta.get("source_filename") != meta.get("source_filename"): return jsonify({"error": "Parent must belong to same source image"}), 400 meta["parent_id"] = parent_id or None meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") return jsonify({"id": obj_id, "parent_id": meta.get("parent_id")}) @app.route("/api/object//generate_details", methods=["POST"]) def generate_object_details(obj_id: str): """ Erzeugt mit Llama 3.2 Vision zusätzliche englische Metadaten zu einem Objekt auf Basis des ausgeschnittenen Objektbildes + vorhandener deutscher Felder. """ meta_path = OBJECTS_DIR / f"{obj_id}.txt" if not meta_path.exists(): return jsonify({"error": "Object not found"}), 404 try: meta = json.loads(meta_path.read_text(encoding="utf-8")) except Exception: return jsonify({"error": "Could not read meta file"}), 500 image_file = meta.get("image_file") if not image_file: return jsonify({"error": "Object has no image_file"}), 400 image_path = OBJECTS_DIR / image_file if not image_path.exists(): return jsonify({"error": "Object image not found"}), 404 # Prompt laden prompt_template = read_prompt( PROMPTS_DIR / "create_details.txt", "Analyze this object and return one JSON object with the requested fields.", ) title_de = meta.get("title_de", "") or "" action_de = meta.get("action_de", "") or "" condition_de = meta.get("condition_de", "") or "" # Platzhalter im Prompt ersetzen (einfache .format-Verwendung) try: prompt = prompt_template.format( title_de=title_de, action_de=action_de, condition_de=condition_de, ) except Exception: # Falls das Format fehlschlägt, einfach Rohprompt + Kontext anhängen prompt = ( f"{prompt_template}\n\n" f"Titel (Deutsch): {title_de}\n" f"Status (Deutsch): {action_de}\n" f"Zustand (Deutsch): {condition_de}\n" ) # Bild als Bytes laden try: img_bytes = image_path.read_bytes() except Exception: return jsonify({"error": "Could not read object image"}), 500 try: response = ollama.chat( model="llama3.2-vision", messages=[{"role": "user", "content": prompt, "images": [img_bytes]}], format="json", options={"temperature": 0}, ) except Exception as e: return jsonify({"error": f"LLM request failed: {e}"}), 500 content = response.get("message", {}).get("content") if not content: return jsonify({"error": "Empty response from LLM"}), 500 try: ai_data = json.loads(content) except Exception: # Fallback: versuchen, das erste JSON-Objekt zu extrahieren try: start = content.index("{") end = content.rindex("}") + 1 ai_data = json.loads(content[start:end]) except Exception: return jsonify({"error": "Could not parse JSON from LLM response"}), 500 # Debug-Ausgabe des rohen AI-Outputs try: print(f"[generate_details] Raw AI data for {obj_id}: {json.dumps(ai_data, ensure_ascii=False)}") except Exception: print(f"[generate_details] Raw AI data for {obj_id} (non-serializable): {ai_data!r}") # Erwartete Felder übernehmen (gemäß neuem Prompt) fields = [ "label_en", "label_de", "label_se", "color_en", "adjective_en", "action_verb_en", "preposition_en", "relative_position_en", "season_en", ] for key in fields: if key in ai_data: meta[key] = ai_data.get(key) # Metadatei zurückschreiben meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") # Nur die neuen Felder zurückgeben (None als leeren String konvertieren) result = {key: (meta.get(key) or "") for key in fields} try: print(f"[generate_details] Stored meta for {obj_id}: {json.dumps(result, ensure_ascii=False)}") except Exception: print(f"[generate_details] Stored meta for {obj_id}: {result!r}") return jsonify(result) def load_sentences_for_object(obj_id: str): """ Lädt alle gespeicherten Sätze für ein Objekt aus sentence_object/.txt. Rückgabe: Liste von Dicts mit mindestens question_en, answer_en, created_at. """ SENTENCE_DIR.mkdir(parents=True, exist_ok=True) sentence_path = SENTENCE_DIR / f"{obj_id}.txt" if not sentence_path.exists(): return [] try: data = json.loads(sentence_path.read_text(encoding="utf-8")) if isinstance(data, list): return data if isinstance(data, dict): # Fallback: einzelnes Objekt in Liste wrappen return [data] return [] except Exception: return [] @app.route("/api/object//sentences", methods=["GET"]) def get_object_sentences(obj_id: str): """ Gibt alle bisher erzeugten KI-Sätze zu einem Objekt zurück. """ sentences = load_sentences_for_object(obj_id) return jsonify({"object_id": obj_id, "sentences": sentences}) @app.route("/api/object//generate_sentence", methods=["POST"]) def generate_object_sentence(obj_id: str): """ Erzeugt mit Llama 3.1 einen neuen englischen Frage-Antwort-Satz zu einem Objekt basierend auf den KI-Details und bisherigen Sätzen. Speichert alle Sätze in sentence_object/.txt. """ # Objekt-Metadaten laden (inkl. KI-Details) meta_path = OBJECTS_DIR / f"{obj_id}.txt" if not meta_path.exists(): return jsonify({"error": "Object not found"}), 404 try: meta = json.loads(meta_path.read_text(encoding="utf-8")) except Exception: return jsonify({"error": "Could not read meta file"}), 500 # Relevante Felder für den Satz (KI-Details) details = { "label_en": meta.get("label_en"), "label_de": meta.get("label_de"), "label_se": meta.get("label_se"), "color_en": meta.get("color_en"), "adjective_en": meta.get("adjective_en"), "action_verb_en": meta.get("action_verb_en"), "preposition_en": meta.get("preposition_en"), "relative_position_en": meta.get("relative_position_en"), "season_en": meta.get("season_en"), "title_de": meta.get("title_de"), "position_de": meta.get("position_de"), "action_de": meta.get("action_de"), "condition_de": meta.get("condition_de"), } # Bisherige Sätze laden previous_sentences = load_sentences_for_object(obj_id) # Prompt laden prompt_template = read_prompt( PROMPTS_DIR / "create_sentence.txt", "Create one new English question_en and answer_en as JSON, based on the object details and avoiding previous sentences.", ) # Vollständigen Prompt zusammensetzen try: details_json = json.dumps(details, ensure_ascii=False, indent=2) prev_json = json.dumps(previous_sentences, ensure_ascii=False, indent=2) except Exception: details_json = str(details) prev_json = str(previous_sentences) prompt = ( f"{prompt_template}\n\n" f"OBJECT_DETAILS_JSON:\n{details_json}\n\n" f"PREVIOUS_SENTENCES_JSON:\n{prev_json}\n" ) # LLM-Aufruf (nur Text, kein Bild nötig) try: response = ollama.chat( model="llama3.1", messages=[{"role": "user", "content": prompt}], format="json", options={"temperature": 0.2}, ) except Exception as e: return jsonify({"error": f"LLM request failed: {e}"}), 500 content = response.get("message", {}).get("content") if not content: return jsonify({"error": "Empty response from LLM"}), 500 try: ai_data = json.loads(content) except Exception: # Fallback: erstes JSON-Objekt extrahieren try: start = content.index("{") end = content.rindex("}") + 1 ai_data = json.loads(content[start:end]) except Exception: return jsonify({"error": "Could not parse JSON from LLM response"}), 500 question_simple = (ai_data.get("question_simple_en") or "").strip() answer_simple = (ai_data.get("answer_simple_en") or "").strip() question_advanced = (ai_data.get("question_advanced_en") or "").strip() answer_advanced = (ai_data.get("answer_advanced_en") or "").strip() if not question_simple or not answer_simple or not question_advanced or not answer_advanced: return jsonify({"error": "LLM response missing required fields (question_simple_en, answer_simple_en, question_advanced_en, answer_advanced_en)"}), 500 # Neuen Satz mit Timestamp anreichern entry = { "object_id": obj_id, "created_at": datetime.now().isoformat(), "question_simple_en": question_simple, "answer_simple_en": answer_simple, "question_advanced_en": question_advanced, "answer_advanced_en": answer_advanced, } sentences = previous_sentences + [entry] # In Datei speichern SENTENCE_DIR.mkdir(parents=True, exist_ok=True) sentence_path = SENTENCE_DIR / f"{obj_id}.txt" sentence_path.write_text(json.dumps(sentences, ensure_ascii=False, indent=2), encoding="utf-8") return jsonify({"object_id": obj_id, "sentence": entry, "count": len(sentences)}) @app.route("/api/image/save", methods=["POST"]) def save_image(): """ Markiert ein Bild und alle zugehörigen Objekte als 'gespeichert', indem der Dateiname um '_saved' erweitert wird und die Metadaten der Objekte angepasst werden. """ data = request.get_json(force=True, silent=True) or {} filename = data.get("filename") if not filename: return jsonify({"error": "Missing filename"}), 400 src_path = PICTURES_DIR / filename if not src_path.exists(): return jsonify({"error": "Image not found"}), 404 if src_path.stem.endswith("_saved"): return jsonify({"error": "Image already saved"}), 400 new_name = f"{src_path.stem}_saved{src_path.suffix}" dst_path = PICTURES_DIR / new_name # Bild umbenennen src_path.rename(dst_path) # Zugehörige Objekte aktualisieren OBJECTS_DIR.mkdir(parents=True, exist_ok=True) for meta_file in OBJECTS_DIR.glob("*.txt"): try: meta = json.loads(meta_file.read_text(encoding="utf-8")) except Exception: continue if meta.get("source_filename") != filename: continue meta["source_filename"] = new_name meta_file.write_text( json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8", ) return jsonify({"old_name": filename, "new_name": new_name}) @app.route("/api/crop", methods=["POST"]) def crop_image(): """ Erwartet JSON: { "filename": "bild.jpg", "selections": [ { "number": 1, "mode": "rect" | "polygon", "bbox": {"x": 10, "y": 20, "width": 200, "height": 150}, // für rect "polygon": [{"x": 10, "y": 20}, ...] // für polygon }, ... ], "title_de": "...", "position_de": "...", "action_de": "...", "condition_de": "..." } ODER (Legacy-Format für Kompatibilität): { "filename": "bild.jpg", "mode": "rect" | "polygon", "x": 10, "y": 20, "width": 200, "height": 150, // für rect "polygon": [{"x": 10, "y": 20}, ...] // für polygon } """ data = request.get_json(force=True, silent=True) or {} if "filename" not in data: return jsonify({"error": "Missing filename"}), 400 src_path = PICTURES_DIR / data["filename"] if src_path.stem.endswith("_saved"): return jsonify({"error": "Cannot crop on saved image"}), 400 if not src_path.exists(): return jsonify({"error": "Source image not found"}), 404 OBJECTS_DIR.mkdir(parents=True, exist_ok=True) # UUID für Bild + Metadaten obj_id = uuid4().hex timestamp = datetime.now().isoformat() out_image_name = f"{obj_id}{src_path.suffix}" out_image_path = OBJECTS_DIR / out_image_name out_meta_path = OBJECTS_DIR / f"{obj_id}.txt" # Prüfen, ob neues Format (selections) oder Legacy-Format selections = data.get("selections") if selections and isinstance(selections, list) and len(selections) > 0: # Neues Format: mehrere Auswahlen processed_selections = [] with Image.open(src_path) as img: img_w, img_h = img.size for sel in selections: sel_mode = sel.get("mode") if sel_mode == "rect": bbox = sel.get("bbox") if not bbox: continue try: x = int(bbox.get("x", 0)) y = int(bbox.get("y", 0)) w = int(bbox.get("width", 0)) h = int(bbox.get("height", 0)) except (ValueError, TypeError): continue if w <= 0 or h <= 0: continue x2 = min(x + w, img_w) y2 = min(y + h, img_h) x1 = max(0, x) y1 = max(0, y) if x1 >= x2 or y1 >= y2: continue processed_selections.append({ "number": sel.get("number", len(processed_selections) + 1), "mode": "rect", "bbox": {"x": x1, "y": y1, "width": x2 - x1, "height": y2 - y1}, }) elif sel_mode == "polygon": polygon = sel.get("polygon") if not isinstance(polygon, list) or len(polygon) < 3: continue try: xs = [int(p.get("x", 0)) for p in polygon] ys = [int(p.get("y", 0)) for p in polygon] except (KeyError, TypeError, ValueError): continue min_x = max(min(xs), 0) min_y = max(min(ys), 0) max_x = min(max(xs), img_w) max_y = min(max(ys), img_h) if min_x >= max_x or min_y >= max_y: continue processed_selections.append({ "number": sel.get("number", len(processed_selections) + 1), "mode": "polygon", "polygon": polygon, "bbox": {"x": min_x, "y": min_y, "width": max_x - min_x, "height": max_y - min_y}, }) if not processed_selections: return jsonify({"error": "No valid selections provided"}), 400 # Erstes Bild aus erster Auswahl erstellen first_sel = processed_selections[0] try: with Image.open(src_path) as img: if first_sel["mode"] == "rect": bbox = first_sel["bbox"] x1 = bbox["x"] y1 = bbox["y"] x2 = x1 + bbox["width"] y2 = y1 + bbox["height"] cropped = img.crop((x1, y1, x2, y2)) else: # polygon - verwende Bounding-Box für das Bild bbox = first_sel["bbox"] x1 = bbox["x"] y1 = bbox["y"] x2 = x1 + bbox["width"] y2 = y1 + bbox["height"] cropped = img.crop((x1, y1, x2, y2)) # Bild speichern (Format beibehalten) img_format = img.format or "PNG" cropped.save(out_image_path, format=img_format) print(f"[crop_image] Bild gespeichert: {out_image_path} (Größe: {cropped.size}, Format: {img_format})") except Exception as e: print(f"[crop_image] Fehler beim Erstellen des Bildes: {e}") return jsonify({"error": f"Failed to create image: {e}"}), 500 # Metadaten mit allen Auswahlen speichern meta = { "id": obj_id, "created_at": timestamp, "source_filename": data["filename"], "mode": processed_selections[0]["mode"], # Legacy-Feld "bbox": processed_selections[0]["bbox"], # Legacy-Feld "image_file": out_image_name, "hierarchy": 1, "parent_id": None, "title_de": data.get("title_de", ""), "position_de": data.get("position_de", ""), "action_de": data.get("action_de", ""), "condition_de": data.get("condition_de", ""), "selections": processed_selections, # Neue Feld: alle Auswahlen nummeriert } if processed_selections[0]["mode"] == "polygon": meta["polygon"] = processed_selections[0]["polygon"] # Legacy-Feld else: # Legacy-Format: einzelne Auswahl (für Rückwärtskompatibilität) mode = data.get("mode", "rect") if mode not in {"rect", "polygon"}: return jsonify({"error": "Invalid mode"}), 400 with Image.open(src_path) as img: img_w, img_h = img.size if mode == "rect": try: x = int(data["x"]) y = int(data["y"]) w = int(data["width"]) h = int(data["height"]) except (ValueError, TypeError, KeyError): return jsonify({"error": "Invalid rectangle coordinates"}), 400 if w <= 0 or h <= 0: return jsonify({"error": "Width and height must be positive"}), 400 x2 = min(x + w, img_w) y2 = min(y + h, img_h) x1 = max(0, x) y1 = max(0, y) if x1 >= x2 or y1 >= y2: return jsonify({"error": "Crop area outside of image"}), 400 cropped = img.crop((x1, y1, x2, y2)) bbox = {"x": x1, "y": y1, "width": x2 - x1, "height": y2 - y1} else: # polygon polygon = data.get("polygon") or [] if not isinstance(polygon, list) or len(polygon) < 3: return jsonify({"error": "Polygon must have at least 3 points"}), 400 try: xs = [int(p["x"]) for p in polygon] ys = [int(p["y"]) for p in polygon] except (KeyError, TypeError, ValueError): return jsonify({"error": "Invalid polygon coordinates"}), 400 min_x = max(min(xs), 0) min_y = max(min(ys), 0) max_x = min(max(xs), img_w) max_y = min(max(ys), img_h) if min_x >= max_x or min_y >= max_y: return jsonify({"error": "Polygon bounding box outside of image"}), 400 cropped = img.crop((min_x, min_y, max_x, max_y)) bbox = { "x": min_x, "y": min_y, "width": max_x - min_x, "height": max_y - min_y, } cropped.save(out_image_path) # Metadaten als JSON in .txt speichern meta = { "id": obj_id, "created_at": timestamp, "source_filename": data["filename"], "mode": mode, "bbox": bbox, "image_file": out_image_name, "hierarchy": 1, "parent_id": None, "title_de": data.get("title_de", ""), "position_de": data.get("position_de", ""), "action_de": data.get("action_de", ""), "condition_de": data.get("condition_de", ""), } if mode == "polygon": meta["polygon"] = data.get("polygon") out_meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") return jsonify( { "id": obj_id, "image_file": out_image_name, "meta_file": out_meta_path.name, } ) if __name__ == "__main__": # Du kannst den Port hier anpassen, z.B. 8000 oder 5000. # Für externen Zugriff später einfach in Nginx/Reverse Proxy weiterleiten. app.run(host="0.0.0.0", port=8000, debug=True)