Download app/ui.py from drowzeys/keys-Auto-Receipts-Studio: direct link, hf CLI and curl.
- Browser
- Download file 23.8 kB
-
https://huggingface.co/drowzeys/keys-Auto-Receipts-Studio/resolve/main/app/ui.py
- Command line
-
hf download hf://drowzeys/keys-Auto-Receipts-Studio/app/ui.py
-
curl -L -o ui.py https://huggingface.co/drowzeys/keys-Auto-Receipts-Studio/resolve/main/app/ui.py
23.8 kB
| from __future__ import annotations | |
| import json | |
| import shutil | |
| import socket | |
| import threading | |
| import uuid | |
| from pathlib import Path | |
| import gradio as gr | |
| from fastapi import FastAPI, File, UploadFile | |
| from fastapi.responses import HTMLResponse, JSONResponse | |
| from app.config import CATEGORIES, DOC_KINDS, Settings, load_settings | |
| from app.db import ( | |
| add_catalog_item, | |
| delete_receipt, | |
| get_receipt, | |
| list_catalog, | |
| list_line_items, | |
| list_receipts, | |
| open_db, | |
| update_extract, | |
| update_receipt_status, | |
| ) | |
| from app.pipeline import process_file | |
| from app.schemas import ReceiptExtract, ReceiptStatus | |
| from app.watcher import start_inbox_watcher | |
| from backends import build_embed, build_llm | |
| CSS = """ | |
| .gradio-container {max-width: 1200px !important;} | |
| """ | |
| PHONE_HTML = """<!doctype html> | |
| <html lang="en"><head> | |
| <meta charset="utf-8"/> | |
| <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"/> | |
| <meta name="apple-mobile-web-app-capable" content="yes"/> | |
| <title>Scan a receipt</title> | |
| <style> | |
| :root { color-scheme: dark; } | |
| * { -webkit-tap-highlight-color: transparent; } | |
| body{font-family:-apple-system,system-ui,sans-serif;background:#111814;color:#e8f0e8;margin:0;padding:24px} | |
| .card{max-width:480px;margin:0 auto;background:#1b2420;border-radius:16px;padding:24px} | |
| h1{font-size:1.5rem;margin:0 0 8px} | |
| p{line-height:1.4;color:#c5d4c8} | |
| /* iOS ignores taps on display:none file inputs. Cover the whole button with an opacity-0 input. */ | |
| .hit{position:relative;display:block;box-sizing:border-box;width:100%;margin:14px 0 0;min-height:56px; | |
| border-radius:12px;font:inherit;font-weight:600;text-align:center;line-height:56px; | |
| overflow:hidden;touch-action:manipulation} | |
| .cam{background:#c4a35a;color:#1b1408} | |
| .lib{background:#2b3a32;color:#e8f0e8;border:1px solid #4d6556} | |
| .hit input[type=file]{ | |
| position:absolute;left:0;top:0;width:100%;height:100%;margin:0;padding:0; | |
| opacity:0;font-size:24px;cursor:pointer;z-index:2} | |
| .ok{color:#9fdfb2;margin-top:12px;word-break:break-word} | |
| .err{color:#ff8a80;margin-top:12px;word-break:break-word} | |
| </style></head> | |
| <body><div class="card"> | |
| <h1>Scan a receipt</h1> | |
| <p>Tap <b>Take photo</b> for the camera, or <b>Choose file</b> for Photos / Files. Use Safari.</p> | |
| <div class="hit cam"><span>Take photo</span> | |
| <input id="cam" type="file" accept="image/*" capture="environment" tabindex="0"/> | |
| </div> | |
| <div class="hit lib"><span>Choose file</span> | |
| <input id="lib" type="file" accept="image/*,image/heic,image/heif,application/pdf,.heic,.heif,.pdf,.jpg,.jpeg,.png" tabindex="0"/> | |
| </div> | |
| <div id="out"></div> | |
| <script> | |
| const out = document.getElementById('out'); | |
| function add(cls, msg) { | |
| const d = document.createElement('div'); | |
| d.className = cls; | |
| d.textContent = msg; | |
| out.prepend(d); | |
| } | |
| async function waitDone(jobId) { | |
| add('ok', 'Uploaded. Reading the receipt on the GPU box…'); | |
| const deadline = Date.now() + 180000; | |
| while (Date.now() < deadline) { | |
| await new Promise(r => setTimeout(r, 2000)); | |
| const r = await fetch('/api/jobs/' + jobId); | |
| const j = await r.json(); | |
| if (j.state === 'processing') continue; | |
| if (j.state === 'done') { | |
| const bits = []; | |
| if (j.vendor) bits.push(j.vendor); | |
| if (j.total) bits.push('$' + j.total); | |
| if (j.category) bits.push(j.category); | |
| if (j.receipt_id != null) bits.push('#' + j.receipt_id); | |
| const line = bits.join(' · ') || j.status || 'done'; | |
| if (j.status === 'failed' || j.status === 'needs_extract') { | |
| add('err', 'Finished with issues: ' + line + (j.error ? ' — ' + j.error : '')); | |
| } else { | |
| add('ok', 'Completed: ' + line + '. Confirm it on the desktop Review tab.'); | |
| } | |
| return; | |
| } | |
| add('err', 'Failed: ' + (j.error || j.state)); | |
| return; | |
| } | |
| add('err', 'Still processing after 3 minutes — check Review on the desktop.'); | |
| } | |
| async function upload(file) { | |
| if (!file) { add('err', 'No file selected'); return; } | |
| add('ok', 'Uploading ' + (file.name || 'photo') + '…'); | |
| const body = new FormData(); | |
| const name = file.name && file.name !== 'image.jpg' ? file.name : ('iphone-' + Date.now() + '.jpg'); | |
| body.append('file', file, name); | |
| try { | |
| const r = await fetch('/api/inbox', { method: 'POST', body }); | |
| const text = await r.text(); | |
| let j; | |
| try { j = JSON.parse(text); } catch (e) { throw new Error('Server returned ' + r.status + ': ' + text.slice(0, 180)); } | |
| if (!r.ok || !j.ok) throw new Error(text.slice(0, 180)); | |
| await waitDone(j.job_id); | |
| } catch (err) { | |
| add('err', String(err)); | |
| } | |
| } | |
| function bind(id) { | |
| const el = document.getElementById(id); | |
| el.addEventListener('change', () => { | |
| const files = el.files; | |
| if (!files || !files.length) { add('err', 'Picker closed with no file'); return; } | |
| for (const f of files) upload(f); | |
| el.value = ''; | |
| }); | |
| } | |
| bind('cam'); | |
| bind('lib'); | |
| </script></div></body></html> | |
| """ | |
| def _lan_ip() -> str: | |
| sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) | |
| try: | |
| sock.connect(("192.0.2.1", 1)) | |
| return sock.getsockname()[0] | |
| except OSError: | |
| return "127.0.0.1" | |
| finally: | |
| sock.close() | |
| def _as_text(raw: object) -> str: | |
| if raw is None: | |
| return "" | |
| if isinstance(raw, list): | |
| return "\n".join(str(part) for part in raw) | |
| return str(raw) | |
| def pretty_json(raw: object) -> str: | |
| text = _as_text(raw).strip() or "{}" | |
| return json.dumps(json.loads(text), indent=2) | |
| def parse_rid(value: object) -> int | None: | |
| if value is None or value == "": | |
| return None | |
| try: | |
| return int(float(str(value))) | |
| except (TypeError, ValueError): | |
| return None | |
| def _table_rows(data: object) -> list[list[object]]: | |
| if data is None: | |
| return [] | |
| if hasattr(data, "values"): | |
| return [list(row) for row in data.values] | |
| return [list(row) for row in data] | |
| def _image_preview(path: str) -> str | None: | |
| if not path: | |
| return None | |
| suffix = Path(path).suffix.lower() | |
| if suffix in {".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff"}: | |
| return path | |
| return None | |
| def _save_upload(settings: Settings, name: str, data: bytes) -> Path: | |
| safe = Path(name).name or "upload.bin" | |
| dest = settings.inbox_dir / safe | |
| n = 1 | |
| while dest.exists(): | |
| dest = settings.inbox_dir / f"{dest.stem}-{n}{dest.suffix}" | |
| n += 1 | |
| dest.write_bytes(data) | |
| return dest | |
| _JOBS: dict[str, dict[str, object]] = {} | |
| _JOBS_LOCK = threading.Lock() | |
| def build_app(settings: Settings) -> FastAPI: | |
| settings.ensure_dirs() | |
| api = FastAPI(title="keys-automatic-receipt-doc-scanner") | |
| async def api_inbox(file: UploadFile = File(...)) -> JSONResponse: | |
| data = await file.read() | |
| dest = _save_upload(settings, file.filename or "iphone.jpg", data) | |
| job_id = uuid.uuid4().hex | |
| with _JOBS_LOCK: | |
| _JOBS[job_id] = {"state": "processing"} | |
| def _run() -> None: | |
| try: | |
| result = process_file(dest, settings) | |
| extract = result.extract | |
| payload: dict[str, object] = { | |
| "state": "done", | |
| "status": result.status.value, | |
| "receipt_id": result.receipt_id, | |
| "vendor": extract.vendor if extract else None, | |
| "total": str(extract.total) if extract and extract.total is not None else None, | |
| "category": extract.category if extract else None, | |
| "error": result.error, | |
| } | |
| except Exception as exc: | |
| payload = {"state": "error", "error": str(exc)} | |
| with _JOBS_LOCK: | |
| _JOBS[job_id] = payload | |
| threading.Thread(target=_run, daemon=True, name="inbox-scan").start() | |
| return JSONResponse( | |
| { | |
| "ok": True, | |
| "job_id": job_id, | |
| "path": str(dest), | |
| "bytes": len(data), | |
| "processing": True, | |
| } | |
| ) | |
| async def api_health() -> JSONResponse: | |
| from app.route import resolve_brain | |
| try: | |
| brain = resolve_brain(settings) | |
| payload = { | |
| "ok": True, | |
| "route": settings.llm_route, | |
| "kind": brain.kind, | |
| "base_url": brain.base_url, | |
| "model": brain.model, | |
| "source": brain.source, | |
| } | |
| except Exception as exc: | |
| payload = {"ok": False, "error": str(exc), "route": settings.llm_route} | |
| return JSONResponse(payload) | |
| async def job_status(job_id: str) -> JSONResponse: | |
| with _JOBS_LOCK: | |
| job = _JOBS.get(job_id) | |
| if job is None: | |
| return JSONResponse({"state": "error", "error": "unknown job"}, status_code=404) | |
| return JSONResponse(job) | |
| async def phone() -> str: | |
| return PHONE_HTML | |
| def health() -> str: | |
| llm = build_llm(settings) | |
| embed = build_embed(settings) | |
| return ( | |
| f"LLM {settings.llm_backend}/{settings.llm_model} @ {settings.llm_base_url} " | |
| f"vision={llm.accepts_images} health={llm.health()}\n" | |
| f"Embed {settings.embed_backend}/{settings.embed_model} dim={settings.embed_dim} " | |
| f"health={embed.health()}\n" | |
| f"Camera {settings.camera_url} idle={settings.idle_seconds}s" | |
| ) | |
| def inbox_list() -> str: | |
| files = sorted(p.name for p in settings.inbox_dir.iterdir() if p.is_file()) | |
| return "\n".join(files) or "(empty inbox)" | |
| def ingest(files: list) -> str: | |
| if not files: | |
| return "no files" | |
| names = [] | |
| for item in files: | |
| src = Path(item if isinstance(item, str) else item.name) | |
| dest = settings.inbox_dir / src.name | |
| shutil.copy2(src, dest) | |
| names.append(dest.name) | |
| return "queued: " + ", ".join(names) | |
| def review_table() -> list[list[str]]: | |
| con = open_db(settings) | |
| try: | |
| rows = list_receipts(con, limit=40) | |
| return [ | |
| [ | |
| str(r["id"]), | |
| r["status"], | |
| r["doc_kind"] or "", | |
| r["category"] or "", | |
| r["vendor"] or "", | |
| r["receipt_date"] or "", | |
| "" if r["total_cents"] is None else f"{r['total_cents']/100:.2f}", | |
| ] | |
| for r in rows | |
| ] | |
| finally: | |
| con.close() | |
| def _empty_load() -> tuple: | |
| return ( | |
| None, | |
| "", | |
| "receipt", | |
| "other", | |
| "", | |
| "", | |
| "", | |
| "", | |
| "", | |
| "{}", | |
| [], | |
| "not found", | |
| ) | |
| def load_one(receipt_id: object) -> tuple: | |
| rid = parse_rid(receipt_id) | |
| if rid is None: | |
| return _empty_load() | |
| con = open_db(settings) | |
| try: | |
| row = get_receipt(con, rid) | |
| if row is None: | |
| return _empty_load() | |
| try: | |
| payload = pretty_json(row["extract_json"] or "{}") | |
| except json.JSONDecodeError: | |
| payload = row["extract_json"] or "{}" | |
| extract = None | |
| try: | |
| extract = ReceiptExtract.model_validate_json(payload) | |
| except Exception: | |
| extract = None | |
| lines = [ | |
| [ | |
| item.description, | |
| "" if item.qty is None else item.qty, | |
| "" if item.unit_price is None else str(item.unit_price), | |
| "" if item.amount is None else str(item.amount), | |
| item.sku or "", | |
| ] | |
| for item in (extract.line_items if extract else []) | |
| ] | |
| if not lines: | |
| lines = [ | |
| [ | |
| r["description"], | |
| r["qty"] if r["qty"] is not None else "", | |
| "" if r["unit_price_cents"] is None else f"{r['unit_price_cents']/100:.2f}", | |
| "" if r["amount_cents"] is None else f"{r['amount_cents']/100:.2f}", | |
| r["sku"] or "", | |
| ] | |
| for r in list_line_items(con, rid) | |
| ] | |
| return ( | |
| _image_preview(row["source_path"] or ""), | |
| str(rid), | |
| (extract.doc_kind if extract else row["doc_kind"]) or "receipt", | |
| (extract.category if extract else row["category"]) or "other", | |
| (extract.vendor if extract else row["vendor"]) or "", | |
| ( | |
| extract.date.isoformat() | |
| if extract and extract.date | |
| else (row["receipt_date"] or "") | |
| ), | |
| "" if extract is None or extract.tax is None else str(extract.tax), | |
| "" if extract is None or extract.total is None else str(extract.total), | |
| (extract.currency if extract else row["currency"]) or "", | |
| payload, | |
| lines, | |
| f"loaded #{rid}", | |
| ) | |
| finally: | |
| con.close() | |
| def load_from_table(data: object, evt: gr.SelectData) -> tuple: | |
| rows = _table_rows(data) | |
| index = evt.index | |
| row_i = index[0] if isinstance(index, (list, tuple)) else index | |
| if row_i is None or row_i < 0 or row_i >= len(rows) or not rows[row_i]: | |
| return _empty_load() | |
| return load_one(rows[row_i][0]) | |
| def save_extract(receipt_id: object, raw: object) -> tuple[str, list[list[str]]]: | |
| rid = parse_rid(receipt_id) | |
| if rid is None: | |
| return "pick a receipt (click a row or enter id)", review_table() | |
| try: | |
| extract = ReceiptExtract.model_validate_json(pretty_json(raw)) | |
| except Exception as exc: | |
| return f"invalid JSON: {exc}", review_table() | |
| con = open_db(settings) | |
| try: | |
| update_extract(con, rid, extract, ReceiptStatus.needs_review) | |
| finally: | |
| con.close() | |
| return f"saved JSON for #{rid}", review_table() | |
| def save_fields( | |
| receipt_id: object, | |
| doc_kind: str, | |
| category: str, | |
| vendor: str, | |
| receipt_date: str, | |
| tax: str, | |
| total: str, | |
| currency: str, | |
| lines: object, | |
| ) -> tuple[str, str, list[list[str]]]: | |
| rid = parse_rid(receipt_id) | |
| if rid is None: | |
| return "pick a receipt first", "{}", review_table() | |
| def _num(val: object) -> str | None: | |
| text = str(val).strip() | |
| return None if text in {"", "None", "null"} else text | |
| items = [] | |
| for row in _table_rows(lines): | |
| if not row or not str(row[0]).strip(): | |
| continue | |
| items.append( | |
| { | |
| "description": str(row[0]).strip(), | |
| "qty": _num(row[1] if len(row) > 1 else None), | |
| "unit_price": _num(row[2] if len(row) > 2 else None), | |
| "amount": _num(row[3] if len(row) > 3 else None), | |
| "sku": (str(row[4]).strip() or None) if len(row) > 4 else None, | |
| } | |
| ) | |
| payload = { | |
| "doc_kind": doc_kind or "receipt", | |
| "category": category or "other", | |
| "vendor": vendor.strip() or None, | |
| "date": receipt_date.strip() or None, | |
| "tax": tax.strip() or None, | |
| "total": total.strip() or None, | |
| "currency": currency.strip() or None, | |
| "line_items": items, | |
| } | |
| raw = json.dumps(payload, indent=2) | |
| try: | |
| extract = ReceiptExtract.model_validate(payload) | |
| except Exception as exc: | |
| return f"invalid fields: {exc}", raw, review_table() | |
| con = open_db(settings) | |
| try: | |
| update_extract(con, rid, extract, ReceiptStatus.needs_review) | |
| finally: | |
| con.close() | |
| return f"saved receipt #{rid}", pretty_json(extract.model_dump_json()), review_table() | |
| def confirm(receipt_id: object) -> tuple[str, list[list[str]]]: | |
| rid = parse_rid(receipt_id) | |
| if rid is None: | |
| return "pick a receipt first", review_table() | |
| con = open_db(settings) | |
| try: | |
| update_receipt_status(con, rid, ReceiptStatus.confirmed) | |
| finally: | |
| con.close() | |
| return f"confirmed #{rid}", review_table() | |
| def delete_one(receipt_id: object) -> tuple: | |
| rid = parse_rid(receipt_id) | |
| empty = _empty_load() | |
| if rid is None: | |
| return (*empty[:-1], "pick a receipt first", review_table()) | |
| con = open_db(settings) | |
| try: | |
| found = delete_receipt(con, rid) | |
| finally: | |
| con.close() | |
| if not found: | |
| return (*empty[:-1], f"not found #{rid}", review_table()) | |
| return (*empty[:-1], f"deleted #{rid}", review_table()) | |
| def catalog_table() -> list[list[str]]: | |
| con = open_db(settings) | |
| try: | |
| return [ | |
| [str(r["id"]), r["sku"] or "", r["vendor"] or "", r["description"]] | |
| for r in list_catalog(con) | |
| ] | |
| finally: | |
| con.close() | |
| def add_sku(sku: str, vendor: str, description: str) -> str: | |
| if not description.strip(): | |
| return "description required" | |
| con = open_db(settings) | |
| try: | |
| add_catalog_item(con, sku=sku or None, vendor=vendor or None, description=description) | |
| finally: | |
| con.close() | |
| return "added" | |
| def process_now(path: str) -> str: | |
| if not path: | |
| return "no path" | |
| result = process_file(Path(path), settings) | |
| return result.model_dump_json(indent=2) | |
| with gr.Blocks(title="Receipt Studio", theme=gr.themes.Soft(primary_hue="amber"), css=CSS) as demo: | |
| gr.Markdown( | |
| "# Receipt Studio\n" | |
| "Lamp camera · phone upload · Gemma 4 12B Unified on the GPU box (not on the Lamp)." | |
| ) | |
| with gr.Tab("Inbox"): | |
| gr.Markdown( | |
| f"Drop files here or on your phone: `http://{_lan_ip()}:{settings.ui_port}/phone` " | |
| f"(bind `0.0.0.0` / `RECEIPT_UI_SHARE_LAN=true`). Syncthing can also land in `inbox/`." | |
| ) | |
| files = gr.File(label="Photos / PDFs", file_count="multiple", type="filepath") | |
| ingest_btn = gr.Button("Queue in inbox") | |
| ingest_out = gr.Textbox(label="Queued") | |
| listing = gr.Textbox(label="Inbox", lines=8) | |
| refresh = gr.Button("Refresh inbox") | |
| ingest_btn.click(ingest, inputs=[files], outputs=[ingest_out]).then( | |
| inbox_list, outputs=[listing] | |
| ) | |
| refresh.click(inbox_list, outputs=[listing]) | |
| demo.load(inbox_list, outputs=[listing]) | |
| with gr.Tab("Review"): | |
| gr.Markdown( | |
| "Click a row to load. Edit **Kind / Category / Vendor / Date / Tax / Total** " | |
| "(or the JSON), then **Save fields + lines**. " | |
| "**Delete** removes a bad scan from the database and disk." | |
| ) | |
| table = gr.Dataframe( | |
| headers=["id", "status", "kind", "category", "vendor", "date", "total"], | |
| datatype=["str"] * 7, | |
| interactive=False, | |
| wrap=True, | |
| ) | |
| refresh_r = gr.Button("Refresh queue") | |
| with gr.Row(): | |
| img = gr.Image(label="Scan", type="filepath") | |
| with gr.Column(): | |
| rid = gr.Textbox(label="Receipt id") | |
| kind = gr.Dropdown(choices=list(DOC_KINDS), label="Kind", value="receipt") | |
| category = gr.Dropdown(choices=list(CATEGORIES), label="Category", value="other") | |
| vendor = gr.Textbox(label="Vendor") | |
| receipt_date = gr.Textbox(label="Date (YYYY-MM-DD)") | |
| tax = gr.Textbox(label="Tax") | |
| total = gr.Textbox(label="Total") | |
| currency = gr.Textbox(label="Currency") | |
| raw = gr.Textbox( | |
| label="Extract JSON (editable)", | |
| lines=18, | |
| max_lines=40, | |
| interactive=True, | |
| ) | |
| lines = gr.Dataframe( | |
| headers=["description", "qty", "unit_price", "amount", "sku"], | |
| datatype=["str", "str", "str", "str", "str"], | |
| label="Line items (editable)", | |
| interactive=True, | |
| wrap=True, | |
| ) | |
| with gr.Row(): | |
| load_btn = gr.Button("Load id") | |
| save_fields_btn = gr.Button("Save fields + lines") | |
| save_json_btn = gr.Button("Save JSON") | |
| ok_btn = gr.Button("Confirm") | |
| del_btn = gr.Button("Delete", variant="stop") | |
| msg = gr.Textbox(label="Status") | |
| load_outputs = [ | |
| img, | |
| rid, | |
| kind, | |
| category, | |
| vendor, | |
| receipt_date, | |
| tax, | |
| total, | |
| currency, | |
| raw, | |
| lines, | |
| msg, | |
| ] | |
| refresh_r.click(review_table, outputs=[table]) | |
| table.select(load_from_table, inputs=[table], outputs=load_outputs) | |
| load_btn.click(load_one, inputs=[rid], outputs=load_outputs) | |
| save_fields_btn.click( | |
| save_fields, | |
| inputs=[rid, kind, category, vendor, receipt_date, tax, total, currency, lines], | |
| outputs=[msg, raw, table], | |
| ) | |
| save_json_btn.click(save_extract, inputs=[rid, raw], outputs=[msg, table]) | |
| ok_btn.click(confirm, inputs=[rid], outputs=[msg, table]) | |
| del_btn.click(delete_one, inputs=[rid], outputs=load_outputs + [table]) | |
| demo.load(review_table, outputs=[table]) | |
| with gr.Tab("Catalog"): | |
| cat = gr.Dataframe(headers=["id", "sku", "vendor", "description"]) | |
| sku = gr.Textbox(label="SKU") | |
| vendor = gr.Textbox(label="Vendor") | |
| desc = gr.Textbox(label="Description") | |
| add_btn = gr.Button("Add SKU") | |
| add_msg = gr.Textbox() | |
| add_btn.click(add_sku, inputs=[sku, vendor, desc], outputs=[add_msg]).then( | |
| catalog_table, outputs=[cat] | |
| ) | |
| demo.load(catalog_table, outputs=[cat]) | |
| with gr.Tab("Settings"): | |
| gr.Markdown( | |
| "Gemma 4 12B Unified does **not** load on the Lamp (6 GB). " | |
| "This UI talks to the GPU box URLs in `.env`." | |
| ) | |
| gr.Textbox(value=health, label="Backends", every=15) | |
| path = gr.Textbox(label="Process this path now") | |
| run = gr.Button("Process") | |
| run_out = gr.Textbox(lines=16) | |
| run.click(process_now, inputs=[path], outputs=[run_out]) | |
| return gr.mount_gradio_app(api, demo, path="/") | |
| def main() -> None: | |
| settings = load_settings() | |
| if settings.ui_share_lan: | |
| settings.ui_host = "0.0.0.0" | |
| start_inbox_watcher(settings) | |
| import uvicorn | |
| uvicorn.run( | |
| build_app(settings), | |
| host=settings.ui_host, | |
| port=settings.ui_port, | |
| log_level="info", | |
| ) | |
| if __name__ == "__main__": | |
| main() | |