b*}`OTjlBi1AeD|yKMgZL$1?V^P(vV_TxJa_ak`Qya}g}!O9
zPt+jgsRv~F;$`i=RbtALa*R
zaQoEtZz6HLFxNEK!}mzbuIOt=rTeZQx&439r#4<@jF!U;Pa;W0n;>=P1~}6`L!qkp
zbRjfDGan%qH9nBAJ3;#?rhJYgeB%XZLULI1R-YlCJ=iIz7GTjXzC>2mV!DyM7=K|S
zELaF_FO`W;q_KMmORcKUk3Iw6dEIs*i0`09rO71<^@BCGu}byEbc?XSdls2a&R#?4
z0Q^+uvCH;x@y|W0g=)f>uaX2FyXH*pXr&^#{k9>8HiyrcqhwC!=nNdeb)19D?ynxi
zDa5xvMz;i)$$wkFj)bM(X8S-O7q^qRiOq_)-{KpM;0!x#rndnViE&_aObhk~0{gNI
zriAuk;kT5i%GcNa&}{|{;76;w-0#dOcP2jB5yNB4%;z6k0pL#iS_bjktMpu%#w}s=
z32Q#N5{WmExTdf)nNzbMV`4m{wKKthWIi`WW~cinW7hiMsW|H?4bx
zG+53yXMqjVSn(+FozpUqYKmvAe28I`4-hw*@6VB!5;Vq*cr+O4!C`>$I
zZ1=I2i!Dj?LrrZL`>-BPTM@}k%7?F+=WITY(U|nd9)+R!WuQ8qO5{NeSs=pdtJ$B4
ze(^>A#<@$MYH}K!T9^fW+p~k?dGs0ExmdR9k8~ZnuygDe8tBH
zbRR*RqbSS0b#GCCa!>nrda|KhgDH*oTJ@|O=G-7A@j8PRD
z49C2h8h@!{i;Q(76IjAxgt4}zkJOXQ4=_!33b@iE(-gI<9d?cu*Ef(O?~5lD^~v;7
z@(%M7uA&wgtLMH1ft%UiYFAvF;*uyHuJ(Wnlk}Df3Wo9J)}gS3Lay1<2)@gr+wvTL
zCus$zEt20hMcOGfiF{9~K~)O2zNwWgpz3Cy5lfj$F!}mml3Prl)gAe#Ynid~c&AOc
zPQ}9`jN?zO2@62_RxG4DNH-H9IWJ!Tvb&R@A;_VnuNDf=M{CdI>p3%-!&AWX_7Lmd
zV_m)LN48GY$xx_&Gka!Y6d1SZe(9W*z~o{Y=1fj)xQl898Mo8PhM~W!f#slYkMR1^
zFyAimC~js=uMR3?=yDQ!L4jU15T5Psz*7%pU1?GNPpUMcCGMoL!NK;Oa9NJ=dB`HL
zYrmla*jMRu7y_@no%M|VYZV5Xeu|(1m8s403D;J!J*LQ5Q
z^#Ld2Nf^#H*pAcy+Cu7HK2a+7m%<6k(OLNZ0bq&qa~4e|tmLzPKF}^lc~a4fNm!`q
z@E%mxD(Bd7zK=xZoC0r{p=#QIj3j;?4)1QFCtozZHc#S>?z8_mH3a;_{SI?&=FxOJ
z5+c)}9@l(iVV>}`5-&8dSPbO8l0QvxwATMOFIX*i!@0ZoY4$j46ie5qTowDeNr~5q4fOHb_ZCG`4C)Uv5%lr?_r}Fie#|HeJ9+DPP1!py
zhqH0y(x)VpNU^M?JR-xA;l;Rt+8)T1Fb|JtN{(K&%B+#yMNJLjzx=Yy0U>DH`z!oZ
zW=3~A7QMOq!R3n>*z>E?#hYb+-K{#;G`ZuQwH+HAC=+6=zZto
zrVR9S2ovhQGOu$Jc$P-);&w``RtTnv@>XIjqGe}2Seec7jt6Ehm-aE=cQN8gk@WRYq9^bSPrR^
z!Gf~4X@Mje*^GSgKV>0
ziC`PGAGla0=K9XIO3-Lsa&4Qu?YBt3IHs0dpP2d23^n1T$gzTb5&BiQ;z~8np-|x(
z>JSbjM0<~}qUKMBE_`wxkV#{J^mRoY2Kk~2uSAEQ0pix4
wA!-EV_I|Lslh^#Fo%G0$_S{kbdpOBt<^9>WPyAh_Bdt{Y!il;N?nw60F0gAntpET3
diff --git a/test-local-vision/analysis/buy-spec.md b/test-local-vision/analysis/buy-spec.md
new file mode 100644
index 0000000..4174116
--- /dev/null
+++ b/test-local-vision/analysis/buy-spec.md
@@ -0,0 +1,287 @@
+# Hardware-Spezifikation: KI-Inferenz-Server für Qwen2.5-VL-72B
+
+**Projekt:** On-Premise GPU-Server für Vision-Language-Inferenz
+**Datum:** 01.02.2026
+**Modell:** Qwen2.5-VL-72B-Instruct
+**Hinweis:** DeepSeek V3.1 läuft bereits lokal auf Notebook — nicht Gegenstand dieser Spezifikation
+
+---
+
+## 1. Modell-Profil: Qwen2.5-VL-72B
+
+| Eigenschaft | Wert |
+|---|---|
+| Parameter (Language Decoder) | 72 Milliarden |
+| Vision Encoder (ViT) | ~675 Millionen |
+| Kontextfenster | 128K Tokens |
+| Bildverarbeitung | Dynamische Auflösung, variable Token-Anzahl pro Bild |
+| Video-Support | Ja (Frame-Sampling mit temporaler Kodierung) |
+| Architektur-Features | Window Attention (ViT), SwiGLU, RMSNorm, mRoPE |
+
+### VRAM-Bedarf der Modellgewichte
+
+| Präzision | Gewichte | Empfohlener VRAM gesamt (inkl. KV-Cache, Aktivierungen) |
+|---|---|---|
+| BF16 (volle Präzision) | ~144 GB | 192–384 GB |
+| FP8 (quantisiert) | ~72 GB | 120–192 GB |
+| INT4 / AWQ | ~36 GB | 80–120 GB |
+
+### Warum Vision-Modelle mehr VRAM brauchen als Text-LLMs
+
+Bei reinen Text-Modellen ist der VRAM-Bedarf relativ vorhersehbar. Bei Vision-Language-Modellen wie Qwen2.5-VL kommt ein **dynamischer Zusatzbedarf** hinzu:
+
+- Jedes Eingabebild wird in eine variable Anzahl visueller Tokens umgewandelt (standardmässig 256–16.384 Tokens pro Bild, steuerbar über `min_pixels` / `max_pixels`).
+- Hochauflösende Bilder oder mehrere Bilder pro Anfrage können den VRAM-Verbrauch sprunghaft erhöhen.
+- Der Vision-Encoder selbst ist mit ~675M Parametern klein, aber die erzeugten visuellen Tokens vergrössern den KV-Cache erheblich.
+- Video-Inputs erzeugen besonders viele Tokens und können den VRAM um ein Vielfaches steigern.
+
+**Praxis-Implikation:** Für Qwen2.5-VL-72B muss deutlich mehr VRAM-Headroom eingeplant werden als für ein reines 72B-Textmodell.
+
+---
+
+## 2. GPU-Konfiguration
+
+### Option A — Empfohlen: 4× NVIDIA RTX 6000 Ada (48 GB)
+
+| Komponente | Spezifikation |
+|---|---|
+| **GPU** | **4× NVIDIA RTX 6000 Ada Generation (48 GB GDDR6 ECC)** |
+| VRAM gesamt | 192 GB |
+| Speicherbandbreite pro GPU | 960 GB/s |
+| FP16 Tensor Performance | 1.457 TFLOPS (mit Sparsity) |
+| Architektur | Ada Lovelace (4. Gen. Tensor Cores) |
+| TDP pro GPU | 300W |
+| Interconnect | PCIe Gen 4 ×16 |
+
+**Begründung:**
+
+- 192 GB Gesamt-VRAM reicht für Qwen2.5-VL-72B in BF16 (~144 GB Gewichte) mit ausreichend Headroom für KV-Cache und Bildverarbeitung.
+- Benchmarks zeigen ~450 Tokens/s Durchsatz für 72B-Modelle auf 4× A6000/RTX 6000 Ada mit vLLM — das ist für die meisten Produktiv-Szenarien schnell genug.
+- Die RTX 6000 Ada übertrifft die ältere A6000 bei Inferenz um ~28% dank neuerer Tensor Cores und höherer Bandbreite.
+- Preis-Leistung ist bei 48-GB-Workstation-GPUs deutlich besser als bei Datacenter-GPUs (A100/H100).
+- 4 GPUs passen in einen Standard-4U-Server ohne exotische Kühlung.
+
+**Deployment-Konfiguration:**
+
+```bash
+# vLLM mit Tensor-Parallelismus über 4 GPUs
+vllm serve Qwen/Qwen2.5-VL-72B-Instruct \
+ --host 0.0.0.0 \
+ --port 8000 \
+ --tensor-parallel-size 4 \
+ --mm-encoder-tp-mode data \
+ --gpu-memory-utilization 0.95 \
+ --max-model-len 65536 \
+ --limit-mm-per-prompt '{"image":4,"video":0}'
+```
+
+**Wichtig:** `--mm-encoder-tp-mode data` ist essenziell — der Vision-Encoder wird im Data-Parallel-Modus betrieben, da er im Vergleich zum 72B-Decoder winzig ist und Tensor-Parallelismus auf dem ViT mehr Kommunikations-Overhead als Gewinn bringt.
+
+### Option B — Performance-Upgrade: 4× NVIDIA L40S (48 GB)
+
+| Komponente | Spezifikation |
+|---|---|
+| **GPU** | **4× NVIDIA L40S (48 GB GDDR6 ECC)** |
+| VRAM gesamt | 192 GB |
+| Speicherbandbreite pro GPU | 864 GB/s |
+| FP16 Tensor Performance | 1.466 TFLOPS (mit Sparsity) |
+| Architektur | Ada Lovelace (Datacenter-Variante) |
+| TDP pro GPU | 350W |
+| Interconnect | PCIe Gen 4 ×16 |
+
+**Begründung:**
+
+- Gleicher VRAM wie RTX 6000 Ada, aber als Datacenter-GPU mit besserem Dauerbetriebs-Support, ECC-Speicher und Fernwartung.
+- Etwas höhere TDP (350W vs. 300W) ermöglicht höhere Sustained Performance.
+- Verfügbar in validierten Server-Plattformen (Supermicro, Dell, Lenovo).
+- Preislich zwischen RTX 6000 Ada und A100 positioniert.
+
+### Option C — Maximale Qualität: 2× NVIDIA A100 SXM 80 GB
+
+| Komponente | Spezifikation |
+|---|---|
+| **GPU** | **2× NVIDIA A100 SXM (80 GB HBM2e)** |
+| VRAM gesamt | 160 GB |
+| Speicherbandbreite pro GPU | 2.039 GB/s |
+| FP16 Tensor Performance | 624 TFLOPS (mit Sparsity) |
+| Architektur | Ampere |
+| TDP pro GPU | 400W |
+| Interconnect | NVLink 3.0 (600 GB/s bidirektional) |
+
+**Begründung:**
+
+- HBM2e bietet doppelt so hohe Speicherbandbreite wie GDDR6 — das beschleunigt die autoregressive Token-Generierung massiv.
+- NVLink ermöglicht schnellere Inter-GPU-Kommunikation als PCIe.
+- **Achtung:** 160 GB Gesamt-VRAM ist knapp. Modellgewichte in BF16 (~144 GB) lassen nur ~16 GB für KV-Cache. Bei grossen Bildern oder Batching wird es eng. Empfohlen nur mit FP8-Quantisierung (~72 GB Gewichte → ~88 GB für KV-Cache).
+- Höherer Preis und eingeschränkte Verfügbarkeit gegenüber RTX 6000 Ada / L40S.
+
+### GPU-Vergleich auf einen Blick
+
+| Kriterium | 4× RTX 6000 Ada | 4× L40S | 2× A100 80GB |
+|---|---|---|---|
+| VRAM gesamt | 192 GB | 192 GB | 160 GB |
+| Bandbreite gesamt | 3.840 GB/s | 3.456 GB/s | 4.078 GB/s |
+| BF16 ohne Quantisierung | ✅ Ja | ✅ Ja | ⚠️ Knapp |
+| KV-Cache Headroom | ~48 GB | ~48 GB | ~16 GB (BF16) |
+| Interconnect | PCIe | PCIe | NVLink |
+| Dauerbetrieb im Rack | ⚠️ Workstation-GPU | ✅ Datacenter-GPU | ✅ Datacenter-GPU |
+| Stromverbrauch | ~1.200W | ~1.400W | ~800W |
+| GPU-Kosten (ca.) | 20.000–28.000 € | 28.000–36.000 € | 30.000–40.000 € |
+| **Empfehlung** | **Bestes Preis-Leistungs-Verhältnis** | **Bester Kompromiss** | Höchste Bandbreite pro GPU |
+
+---
+
+## 3. Gesamtsystem-Spezifikation (basierend auf Option A/B)
+
+### Server-Plattform
+
+| Komponente | Spezifikation |
+|---|---|
+| Formfaktor | 4U Rackmount |
+| Plattform-Beispiele | Supermicro SYS-421GE-TNRT, Dell PowerEdge R760xa, Lenovo ThinkSystem SR675 V3 |
+| GPU-Slots | 4× PCIe Gen 4/5 ×16 (Double-Width) |
+
+### CPU
+
+| Komponente | Spezifikation |
+|---|---|
+| Prozessor | 1× AMD EPYC 9354 (32 Cores, 3.25 GHz) oder Intel Xeon w5-3435X |
+| PCIe-Lanes | Mind. 64 Lanes PCIe Gen 4/5 (16 pro GPU) |
+| Hinweis | CPU ist nicht der Flaschenhals — wichtig sind genügend PCIe-Lanes |
+
+### Arbeitsspeicher (RAM)
+
+| Komponente | Spezifikation |
+|---|---|
+| Kapazität | 256 GB DDR5-4800 ECC RDIMM |
+| Konfiguration | 8× 32 GB DIMMs, alle Kanäle belegt |
+| Begründung | Modell wird beim Start in RAM geladen (~144 GB), bevor es auf GPUs verteilt wird |
+
+### Storage
+
+| Komponente | Spezifikation |
+|---|---|
+| Primär (Modell) | 1× 2 TB NVMe PCIe Gen 4 SSD |
+| Sekundär (OS/Logs) | 1× 500 GB NVMe SSD |
+| Begründung | Qwen2.5-VL-72B in BF16 ≈ 144 GB, plus FP8- und AWQ-Varianten, Tokenizer, Config |
+
+### Netzwerk
+
+| Komponente | Spezifikation |
+|---|---|
+| Primär (API) | 2× 10/25 GbE (Bonding, Redundanz) |
+| Management | 1× 1 GbE IPMI/BMC |
+| Hinweis | Für Bildübertragung an die API genügt 10 GbE, bei Video-Workloads 25 GbE empfohlen |
+
+### Stromversorgung
+
+| Komponente | Spezifikation |
+|---|---|
+| Netzteil | 2× redundant, mind. 2.400W gesamt |
+| Geschätzte TDP | ~1.800–2.200W unter Volllast (4× GPU + System) |
+| Rack-Anschluss | 1× 16A/230V CEE oder 2× C19/C20 |
+
+---
+
+## 4. Software-Stack
+
+### Betriebssystem und Treiber
+
+| Komponente | Empfehlung |
+|---|---|
+| OS | Ubuntu 22.04 LTS Server |
+| NVIDIA-Treiber | 550+ (Data Center Driver Branch) |
+| CUDA | 12.4+ |
+| Container | Docker + NVIDIA Container Toolkit |
+
+### Inferenz-Framework
+
+| Framework | Eignung für Qwen2.5-VL | Hinweis |
+|---|---|---|
+| **vLLM** (empfohlen) | ✅ Voller VLM-Support | Offiziell dokumentiertes Setup, Tensor- und Data-Parallelismus, OpenAI-kompatible API |
+| SGLang | ✅ Unterstützt | Gute Performance, weniger dokumentiert für VLMs |
+| TensorRT-LLM | ⚠️ Eingeschränkt | VLM-Support im Aufbau, beste Performance wenn verfügbar |
+
+### Optimierungs-Parameter
+
+| Parameter | Empfehlung | Wirkung |
+|---|---|---|
+| `--max-model-len` | 65536 (statt 128K default) | Reduziert KV-Cache-Reservierung erheblich |
+| `--gpu-memory-utilization` | 0.95 | Nutzt fast den gesamten VRAM |
+| `--limit-mm-per-prompt` | `{"image":4,"video":0}` | Begrenzt Bilder pro Anfrage, verhindert VRAM-Spikes |
+| `min_pixels` / `max_pixels` | `256×28×28` / `1280×28×28` | Im Processor setzen — grösster Hebel für VRAM-Einsparung |
+| Flash Attention 2 | Aktivieren | Reduziert VRAM für Attention signifikant, besonders bei vielen visuellen Tokens |
+| FP8-Quantisierung | Optional (RedHatAI-Variante) | Halbiert VRAM der Gewichte, bis zu 1,8× Speedup laut Benchmarks |
+
+### Produktions-Deployment
+
+```bash
+# Empfohlenes Setup: vLLM mit Qwen2.5-VL-72B auf 4× RTX 6000 Ada
+docker run --gpus all \
+ -p 8000:8000 \
+ vllm/vllm-openai:latest \
+ --model Qwen/Qwen2.5-VL-72B-Instruct \
+ --tensor-parallel-size 4 \
+ --mm-encoder-tp-mode data \
+ --gpu-memory-utilization 0.95 \
+ --max-model-len 65536 \
+ --limit-mm-per-prompt '{"image":4,"video":0}' \
+ --host 0.0.0.0 \
+ --port 8000
+```
+
+**API-Zugriff** (OpenAI-kompatibel):
+
+```python
+from openai import OpenAI
+import base64
+
+client = OpenAI(base_url="http://:8000/v1", api_key="dummy")
+
+with open("dokument.png", "rb") as f:
+ img_b64 = base64.b64encode(f.read()).decode()
+
+response = client.chat.completions.create(
+ model="Qwen/Qwen2.5-VL-72B-Instruct",
+ messages=[{
+ "role": "user",
+ "content": [
+ {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_b64}"}},
+ {"type": "text", "text": "Analysiere dieses Dokument."}
+ ]
+ }],
+ max_tokens=2048
+)
+```
+
+---
+
+## 5. Kostenübersicht
+
+| Position | Option A (4× RTX 6000 Ada) | Option B (4× L40S) |
+|---|---|---|
+| GPUs | 20.000–28.000 € | 28.000–36.000 € |
+| Server (CPU, RAM, Storage, Gehäuse, PSU) | 8.000–12.000 € | 8.000–12.000 € |
+| Netzwerk (NICs, Kabel) | 500–1.500 € | 500–1.500 € |
+| **Gesamtsystem** | **~30.000–42.000 €** | **~38.000–50.000 €** |
+
+---
+
+## 6. Empfehlung
+
+**Für Qwen2.5-VL-72B als dedizierter Vision-Server empfehlen wir Option A (4× RTX 6000 Ada) als bestes Preis-Leistungs-Verhältnis** oder **Option B (4× L40S) bei Bedarf an Datacenter-Zertifizierung und Dauerbetrieb.**
+
+Beide Konfigurationen bieten:
+
+- Qwen2.5-VL-72B in voller BF16-Präzision ohne Quantisierungsverlust
+- Ausreichend VRAM-Headroom für hochauflösende Bilder und moderate Batch-Sizes
+- ~450 Tokens/s Durchsatz bei Textgenerierung
+- Skalierungspfad: FP8-Quantisierung verdoppelt den verfügbaren KV-Cache bei minimalem Qualitätsverlust
+- Passend für Standard-19"-Rack (4U), handhabbare Stromaufnahme (~2 kW)
+
+### Vor Beschaffung klären
+
+1. **Gleichzeitige Nutzer:** Bei >10 parallelen Bild-Anfragen wird der VRAM-Headroom knapp. Dann FP8-Variante nutzen oder auf 8 GPUs erweitern.
+2. **Bildauflösung:** Standard-Dokumente (A4-Scans, Screenshots) sind unkritisch. Hochauflösende Fotos oder Multi-Image-Workflows brauchen striktere `max_pixels`-Limits.
+3. **Video-Anforderungen:** Video-Inferenz ist drastisch VRAM-intensiver und langsamer. Falls benötigt, unbedingt `max_pixels` begrenzen und separates Benchmarking durchführen.
+4. **Rack-Infrastruktur:** 2 kW Abwärme und 4U Platzbedarf im bestehenden Rack einplanen. Luftkühlung ist bei 4 GPUs à 300W noch problemlos machbar.
\ No newline at end of file
diff --git a/test-local-vision/app.py b/test-local-vision/app.py
index b805d73..36d5170 100644
--- a/test-local-vision/app.py
+++ b/test-local-vision/app.py
@@ -98,6 +98,24 @@ def _renderPdfPageAsImage(pdfBytes, pageNum=0, zoom=2.0):
return result
+# ============================================================================
+# Model Helper Functions
+# ============================================================================
+
+def _isVisionModel(modelName):
+ """
+ Prüft ob ein Modell ein Vision-Modell ist basierend auf Namenskonventionen.
+ Vision-Modelle enthalten typischerweise 'vision', 'vl', 'llava', 'bakllava' im Namen.
+ """
+ if not modelName:
+ return False
+
+ modelLower = modelName.lower()
+ visionIndicators = ['vision', 'vl', 'llava', 'bakllava']
+
+ return any(indicator in modelLower for indicator in visionIndicators)
+
+
# ============================================================================
# Routes
# ============================================================================
@@ -111,8 +129,8 @@ def _index():
@app.route('/api/analyze', methods=['POST'])
def _analyzeDocument():
"""
- Analysiert ein Dokument mit Ollama Vision API
- Erwartet: { imageBase64, prompt, ollamaUrl, modelName }
+ Analysiert ein Dokument mit Ollama Vision API oder verarbeitet Text mit Non-Vision Modellen
+ Erwartet: { imageBase64 (optional bei Non-Vision), prompt, ollamaUrl, modelName }
"""
try:
data = request.get_json()
@@ -122,21 +140,31 @@ def _analyzeDocument():
ollamaUrl = data.get('ollamaUrl', 'http://localhost:11434')
modelName = data.get('modelName', 'qwen2.5vl:72b')
- if not imageBase64:
- return jsonify({'error': 'Kein Bild übermittelt'}), 400
+ # Prüfe ob es ein Vision-Modell ist (basierend auf Namenskonvention)
+ isVisionModel = _isVisionModel(modelName)
+
+ # Bei Vision-Modellen ist ein Bild erforderlich
+ if isVisionModel and not imageBase64:
+ return jsonify({'error': 'Kein Bild übermittelt (erforderlich für Vision-Modelle)'}), 400
if not prompt:
return jsonify({'error': 'Kein Prompt übermittelt'}), 400
+ # Request-Body erstellen
+ requestBody = {
+ 'model': modelName,
+ 'prompt': prompt,
+ 'stream': False
+ }
+
+ # Bilder nur hinzufügen wenn vorhanden (für Vision-Modelle)
+ if imageBase64:
+ requestBody['images'] = [imageBase64]
+
# Ollama API aufrufen (Timeout: 60 Minuten für grosse Modelle)
response = requests.post(
f'{ollamaUrl}/api/generate',
- json={
- 'model': modelName,
- 'prompt': prompt,
- 'images': [imageBase64],
- 'stream': False
- },
+ json=requestBody,
timeout=3600 # 60 Minuten
)
@@ -153,15 +181,22 @@ def _analyzeDocument():
responseData = response.json()
responseText = responseData.get('response', '')
- # JSON aus der Antwort extrahieren
+ # Versuche JSON aus der Antwort zu extrahieren
+ extractedData = None
jsonMatch = re.search(r'\{[\s\S]*\}', responseText)
- if not jsonMatch:
- return jsonify({
- 'error': 'Keine JSON-Daten in der Antwort gefunden',
- 'rawResponse': responseText
- }), 400
- extractedData = json.loads(jsonMatch.group())
+ if jsonMatch:
+ try:
+ extractedData = json.loads(jsonMatch.group())
+ except json.JSONDecodeError:
+ # JSON-ähnlicher Text gefunden, aber ungültig
+ extractedData = None
+
+ # Wenn kein JSON gefunden, Antwort in JSON-Objekt verpacken
+ if extractedData is None:
+ extractedData = {
+ 'response': responseText.strip()
+ }
return jsonify({
'success': True,
diff --git a/test-local-vision/templates/index.html b/test-local-vision/templates/index.html
index 255a1e4..2abfbcd 100644
--- a/test-local-vision/templates/index.html
+++ b/test-local-vision/templates/index.html
@@ -677,7 +677,7 @@
-
+
+
@@ -913,6 +855,9 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.
ollamaStatusDiv.innerHTML = `✓ Verbunden - ${result.totalModels} Modelle gefunden` +
(result.visionModels?.length ? ` (${result.visionModels.length} Vision-Modelle)` : '');
+
+ // Button-Status nach Modell-Laden aktualisieren
+ _updateAnalyzeButtonState();
} else {
ollamaStatusDiv.className = 'ollama-status error';
ollamaStatusDiv.textContent = `✗ ${result.error}`;
@@ -924,6 +869,33 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.
}
}
+ // Helper: Prüft ob Modell ein Vision-Modell ist
+ function _isVisionModel(model) {
+ if (!model) return true; // Default: als Vision behandeln
+ const modelLower = model.toLowerCase();
+ return ['vision', 'vl', 'llava', 'bakllava'].some(indicator => modelLower.includes(indicator));
+ }
+
+ // Button-Status basierend auf Modell und Bild aktualisieren
+ function _updateAnalyzeButtonState() {
+ const isVision = _isVisionModel(modelName.value);
+ const hasImage = !!currentImageBase64;
+ const hasPrompt = promptInput.value.trim().length > 0;
+
+ // Vision-Modelle brauchen Bild, Non-Vision nicht
+ if (isVision) {
+ analyzeBtn.disabled = !hasImage;
+ } else {
+ analyzeBtn.disabled = !hasPrompt;
+ }
+ }
+
+ // Bei Modell-Wechsel Button-Status aktualisieren
+ modelName.addEventListener('change', _updateAnalyzeButtonState);
+
+ // Bei Prompt-Änderung Button-Status aktualisieren (für Non-Vision Modelle)
+ promptInput.addEventListener('input', _updateAnalyzeButtonState);
+
// Beim Laden automatisch prüfen
window.addEventListener('load', () => {
setTimeout(_checkOllamaStatus, 500);
@@ -989,7 +961,7 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.
uploadPlaceholder.style.display = 'none';
uploadZone.classList.add('has-image');
imageActions.style.display = 'flex';
- analyzeBtn.disabled = false;
+ _updateAnalyzeButtonState();
};
reader.readAsDataURL(file);
}
@@ -1030,7 +1002,7 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.
uploadPlaceholder.style.display = 'none';
uploadZone.classList.add('has-image');
imageActions.style.display = 'flex';
- analyzeBtn.disabled = false;
+ _updateAnalyzeButtonState();
// Page Selector anzeigen wenn mehrere Seiten
if (totalPdfPages > 1) {
@@ -1103,7 +1075,7 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.
imageActions.style.display = 'none';
pdfPageSelector.style.display = 'none';
fileInput.value = '';
- analyzeBtn.disabled = true;
+ _updateAnalyzeButtonState();
}
resetPromptBtn.addEventListener('click', () => {
@@ -1114,11 +1086,23 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.
analyzeBtn.addEventListener('click', _analyzeDocument);
async function _analyzeDocument() {
- if (!currentImageBase64) return;
+ const isVision = _isVisionModel(modelName.value);
+
+ // Vision-Modelle brauchen ein Bild
+ if (isVision && !currentImageBase64) {
+ _showError('Bitte laden Sie zuerst ein Bild oder PDF hoch (erforderlich für Vision-Modelle).');
+ return;
+ }
// Prüfen ob Modell gewählt
if (!modelName.value) {
- _showError('Bitte wählen Sie zuerst ein Vision-Modell aus. Klicken Sie auf "Prüfen" um die verfügbaren Modelle zu laden.');
+ _showError('Bitte wählen Sie zuerst ein Modell aus. Klicken Sie auf "Prüfen" um die verfügbaren Modelle zu laden.');
+ return;
+ }
+
+ // Prüfen ob Prompt vorhanden
+ if (!promptInput.value.trim()) {
+ _showError('Bitte geben Sie einen Prompt ein.');
return;
}
@@ -1127,16 +1111,23 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.
_hideError();
try {
+ // Request-Body erstellen
+ const requestBody = {
+ prompt: promptInput.value,
+ ollamaUrl: ollamaUrl.value,
+ modelName: modelName.value
+ };
+
+ // Bild nur hinzufügen wenn vorhanden
+ if (currentImageBase64) {
+ requestBody.imageBase64 = currentImageBase64;
+ }
+
// Call Python backend API (has CORS enabled)
const response = await fetch('/api/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- imageBase64: currentImageBase64,
- prompt: promptInput.value,
- ollamaUrl: ollamaUrl.value,
- modelName: modelName.value
- })
+ body: JSON.stringify(requestBody)
});
const result = await response.json();
@@ -1189,24 +1180,86 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.
errorContainer.style.display = 'none';
formContainer.style.display = 'block';
- // Fill form fields
- document.getElementById('field-haendler').value = data.haendler || '';
- document.getElementById('field-datum').value = data.datum || '';
- document.getElementById('field-betrag_brutto').value = data.betrag_brutto || '';
- document.getElementById('field-betrag_netto').value = data.betrag_netto || '';
- document.getElementById('field-mwst_betrag').value = data.mwst_betrag || '';
- document.getElementById('field-mwst_satz').value = data.mwst_satz || '';
- document.getElementById('field-waehrung').value = data.waehrung || '';
- document.getElementById('field-kategorie').value = data.kategorie || '';
- document.getElementById('field-zahlungsart').value = data.zahlungsart || '';
- document.getElementById('field-rechnungsnummer').value = data.rechnungsnummer || '';
- document.getElementById('field-positionen').value =
- Array.isArray(data.positionen) ? data.positionen.join('\n') : (data.positionen || '');
- document.getElementById('field-notizen').value = data.notizen || '';
+ // Dynamische Felder Container
+ const dynamicFields = document.getElementById('dynamic-fields');
+ dynamicFields.innerHTML = '';
+
+ // Alle Keys aus dem JSON (Ebene 1) als Felder rendern
+ const keys = Object.keys(data);
+
+ // Felder in 2er-Reihen anordnen (ausser bei langen Werten)
+ let currentRow = null;
+ let fieldsInRow = 0;
+
+ keys.forEach((key, index) => {
+ const value = data[key];
+ const valueStr = _formatValue(value);
+ const isLongValue = valueStr.length > 50 || Array.isArray(value) || (typeof value === 'object' && value !== null);
+
+ // Neue Reihe starten wenn nötig
+ if (!currentRow || fieldsInRow >= 2 || isLongValue) {
+ currentRow = document.createElement('div');
+ currentRow.className = isLongValue ? 'form-field' : 'form-row';
+ dynamicFields.appendChild(currentRow);
+ fieldsInRow = 0;
+ }
+
+ // Feld-Container erstellen
+ const fieldDiv = document.createElement('div');
+ fieldDiv.className = 'form-field';
+
+ // Label erstellen (Key formatieren)
+ const label = document.createElement('label');
+ label.textContent = _formatLabel(key);
+ fieldDiv.appendChild(label);
+
+ // Input oder Textarea erstellen
+ if (isLongValue) {
+ const textarea = document.createElement('textarea');
+ textarea.id = `field-${key}`;
+ textarea.rows = Math.min(Math.max(3, valueStr.split('\n').length), 10);
+ textarea.value = valueStr;
+ fieldDiv.appendChild(textarea);
+ currentRow.appendChild(fieldDiv);
+ // Nach Textarea neue Reihe starten
+ currentRow = null;
+ fieldsInRow = 0;
+ } else {
+ const input = document.createElement('input');
+ input.type = 'text';
+ input.id = `field-${key}`;
+ input.value = valueStr;
+ fieldDiv.appendChild(input);
+
+ if (currentRow.className === 'form-row') {
+ currentRow.appendChild(fieldDiv);
+ fieldsInRow++;
+ } else {
+ currentRow.appendChild(fieldDiv);
+ }
+ }
+ });
// Raw JSON
rawJson.textContent = JSON.stringify(data, null, 2);
}
+
+ // Hilfsfunktion: Wert für Anzeige formatieren
+ function _formatValue(value) {
+ if (value === null || value === undefined) return '';
+ if (Array.isArray(value)) return value.join('\n');
+ if (typeof value === 'object') return JSON.stringify(value, null, 2);
+ return String(value);
+ }
+
+ // Hilfsfunktion: Key als Label formatieren (snake_case -> Title Case)
+ function _formatLabel(key) {
+ return key
+ .replace(/_/g, ' ')
+ .replace(/([A-Z])/g, ' $1')
+ .replace(/^./, str => str.toUpperCase())
+ .trim();
+ }
// Toggle raw JSON
let rawVisible = false;
@@ -1216,22 +1269,26 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.
toggleRaw.querySelector('span:first-child').textContent = rawVisible ? '▼' : '▶';
});
- // Get current form data
+ // Get current form data (dynamisch aus allen Feldern)
function _getCurrentFormData() {
- return {
- haendler: document.getElementById('field-haendler').value || null,
- datum: document.getElementById('field-datum').value || null,
- betrag_brutto: document.getElementById('field-betrag_brutto').value || null,
- betrag_netto: document.getElementById('field-betrag_netto').value || null,
- mwst_betrag: document.getElementById('field-mwst_betrag').value || null,
- mwst_satz: document.getElementById('field-mwst_satz').value || null,
- waehrung: document.getElementById('field-waehrung').value || null,
- kategorie: document.getElementById('field-kategorie').value || null,
- zahlungsart: document.getElementById('field-zahlungsart').value || null,
- rechnungsnummer: document.getElementById('field-rechnungsnummer').value || null,
- positionen: document.getElementById('field-positionen').value.split('\n').filter(p => p.trim()),
- notizen: document.getElementById('field-notizen').value || null
- };
+ const data = {};
+ const dynamicFields = document.getElementById('dynamic-fields');
+ const inputs = dynamicFields.querySelectorAll('input, textarea');
+
+ inputs.forEach(input => {
+ // Key aus ID extrahieren (field-xxx -> xxx)
+ const key = input.id.replace('field-', '');
+ const value = input.value.trim();
+
+ // Mehrzeilige Werte als Array speichern
+ if (input.tagName === 'TEXTAREA' && value.includes('\n')) {
+ data[key] = value.split('\n').filter(line => line.trim());
+ } else {
+ data[key] = value || null;
+ }
+ });
+
+ return data;
}
// Copy JSON
@@ -1249,7 +1306,8 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
- a.download = `beleg-${data.datum || 'export'}.json`;
+ const timestamp = new Date().toISOString().slice(0, 10);
+ a.download = `result-${timestamp}.json`;
a.click();
URL.revokeObjectURL(url);
});