local llm mvp demo
This commit is contained in:
parent
00daebe4f5
commit
c8878489b5
4 changed files with 499 additions and 119 deletions
Binary file not shown.
287
test-local-vision/analysis/buy-spec.md
Normal file
287
test-local-vision/analysis/buy-spec.md
Normal file
|
|
@ -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://<server-ip>: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.
|
||||||
|
|
@ -98,6 +98,24 @@ def _renderPdfPageAsImage(pdfBytes, pageNum=0, zoom=2.0):
|
||||||
|
|
||||||
return result
|
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
|
# Routes
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
@ -111,8 +129,8 @@ def _index():
|
||||||
@app.route('/api/analyze', methods=['POST'])
|
@app.route('/api/analyze', methods=['POST'])
|
||||||
def _analyzeDocument():
|
def _analyzeDocument():
|
||||||
"""
|
"""
|
||||||
Analysiert ein Dokument mit Ollama Vision API
|
Analysiert ein Dokument mit Ollama Vision API oder verarbeitet Text mit Non-Vision Modellen
|
||||||
Erwartet: { imageBase64, prompt, ollamaUrl, modelName }
|
Erwartet: { imageBase64 (optional bei Non-Vision), prompt, ollamaUrl, modelName }
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
|
@ -122,21 +140,31 @@ def _analyzeDocument():
|
||||||
ollamaUrl = data.get('ollamaUrl', 'http://localhost:11434')
|
ollamaUrl = data.get('ollamaUrl', 'http://localhost:11434')
|
||||||
modelName = data.get('modelName', 'qwen2.5vl:72b')
|
modelName = data.get('modelName', 'qwen2.5vl:72b')
|
||||||
|
|
||||||
if not imageBase64:
|
# Prüfe ob es ein Vision-Modell ist (basierend auf Namenskonvention)
|
||||||
return jsonify({'error': 'Kein Bild übermittelt'}), 400
|
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:
|
if not prompt:
|
||||||
return jsonify({'error': 'Kein Prompt übermittelt'}), 400
|
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)
|
# Ollama API aufrufen (Timeout: 60 Minuten für grosse Modelle)
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f'{ollamaUrl}/api/generate',
|
f'{ollamaUrl}/api/generate',
|
||||||
json={
|
json=requestBody,
|
||||||
'model': modelName,
|
|
||||||
'prompt': prompt,
|
|
||||||
'images': [imageBase64],
|
|
||||||
'stream': False
|
|
||||||
},
|
|
||||||
timeout=3600 # 60 Minuten
|
timeout=3600 # 60 Minuten
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -153,15 +181,22 @@ def _analyzeDocument():
|
||||||
responseData = response.json()
|
responseData = response.json()
|
||||||
responseText = responseData.get('response', '')
|
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)
|
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({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
|
|
|
||||||
|
|
@ -677,7 +677,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<label>Ollama:</label>
|
<label>Server:</label>
|
||||||
<input type="text" id="ollama-url" value="http://localhost:11434" placeholder="http://localhost:11434">
|
<input type="text" id="ollama-url" value="http://localhost:11434" placeholder="http://localhost:11434">
|
||||||
<button class="btn btn-secondary btn-small" id="check-ollama">Prüfen</button>
|
<button class="btn btn-secondary btn-small" id="check-ollama">Prüfen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -741,66 +741,8 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div id="error-container" style="display: none;"></div>
|
<div id="error-container" style="display: none;"></div>
|
||||||
<div id="form-container" style="display: none;">
|
<div id="form-container" style="display: none;">
|
||||||
<div class="form-grid">
|
<!-- Dynamisch generierte Felder -->
|
||||||
<div class="form-row">
|
<div id="dynamic-fields" class="form-grid"></div>
|
||||||
<div class="form-field">
|
|
||||||
<label>Händler</label>
|
|
||||||
<input type="text" id="field-haendler">
|
|
||||||
</div>
|
|
||||||
<div class="form-field">
|
|
||||||
<label>Datum</label>
|
|
||||||
<input type="text" id="field-datum">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-field">
|
|
||||||
<label>Betrag Brutto</label>
|
|
||||||
<input type="text" id="field-betrag_brutto">
|
|
||||||
</div>
|
|
||||||
<div class="form-field">
|
|
||||||
<label>Währung</label>
|
|
||||||
<input type="text" id="field-waehrung">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-field">
|
|
||||||
<label>Betrag Netto</label>
|
|
||||||
<input type="text" id="field-betrag_netto">
|
|
||||||
</div>
|
|
||||||
<div class="form-field">
|
|
||||||
<label>MwSt Betrag</label>
|
|
||||||
<input type="text" id="field-mwst_betrag">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-field">
|
|
||||||
<label>MwSt Satz</label>
|
|
||||||
<input type="text" id="field-mwst_satz">
|
|
||||||
</div>
|
|
||||||
<div class="form-field">
|
|
||||||
<label>Zahlungsart</label>
|
|
||||||
<input type="text" id="field-zahlungsart">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-field">
|
|
||||||
<label>Kategorie</label>
|
|
||||||
<input type="text" id="field-kategorie">
|
|
||||||
</div>
|
|
||||||
<div class="form-field">
|
|
||||||
<label>Rechnungsnummer</label>
|
|
||||||
<input type="text" id="field-rechnungsnummer">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-field">
|
|
||||||
<label>Positionen</label>
|
|
||||||
<textarea id="field-positionen" rows="3"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-field">
|
|
||||||
<label>Notizen</label>
|
|
||||||
<textarea id="field-notizen" rows="2"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn btn-primary" id="copy-json">📋 JSON kopieren</button>
|
<button class="btn btn-primary" id="copy-json">📋 JSON kopieren</button>
|
||||||
|
|
@ -913,6 +855,9 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.</textarea>
|
||||||
|
|
||||||
ollamaStatusDiv.innerHTML = `✓ Verbunden - ${result.totalModels} Modelle gefunden` +
|
ollamaStatusDiv.innerHTML = `✓ Verbunden - ${result.totalModels} Modelle gefunden` +
|
||||||
(result.visionModels?.length ? ` (${result.visionModels.length} Vision-Modelle)` : '');
|
(result.visionModels?.length ? ` (${result.visionModels.length} Vision-Modelle)` : '');
|
||||||
|
|
||||||
|
// Button-Status nach Modell-Laden aktualisieren
|
||||||
|
_updateAnalyzeButtonState();
|
||||||
} else {
|
} else {
|
||||||
ollamaStatusDiv.className = 'ollama-status error';
|
ollamaStatusDiv.className = 'ollama-status error';
|
||||||
ollamaStatusDiv.textContent = `✗ ${result.error}`;
|
ollamaStatusDiv.textContent = `✗ ${result.error}`;
|
||||||
|
|
@ -924,6 +869,33 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.</textarea>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Beim Laden automatisch prüfen
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
setTimeout(_checkOllamaStatus, 500);
|
setTimeout(_checkOllamaStatus, 500);
|
||||||
|
|
@ -989,7 +961,7 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.</textarea>
|
||||||
uploadPlaceholder.style.display = 'none';
|
uploadPlaceholder.style.display = 'none';
|
||||||
uploadZone.classList.add('has-image');
|
uploadZone.classList.add('has-image');
|
||||||
imageActions.style.display = 'flex';
|
imageActions.style.display = 'flex';
|
||||||
analyzeBtn.disabled = false;
|
_updateAnalyzeButtonState();
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
}
|
}
|
||||||
|
|
@ -1030,7 +1002,7 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.</textarea>
|
||||||
uploadPlaceholder.style.display = 'none';
|
uploadPlaceholder.style.display = 'none';
|
||||||
uploadZone.classList.add('has-image');
|
uploadZone.classList.add('has-image');
|
||||||
imageActions.style.display = 'flex';
|
imageActions.style.display = 'flex';
|
||||||
analyzeBtn.disabled = false;
|
_updateAnalyzeButtonState();
|
||||||
|
|
||||||
// Page Selector anzeigen wenn mehrere Seiten
|
// Page Selector anzeigen wenn mehrere Seiten
|
||||||
if (totalPdfPages > 1) {
|
if (totalPdfPages > 1) {
|
||||||
|
|
@ -1103,7 +1075,7 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.</textarea>
|
||||||
imageActions.style.display = 'none';
|
imageActions.style.display = 'none';
|
||||||
pdfPageSelector.style.display = 'none';
|
pdfPageSelector.style.display = 'none';
|
||||||
fileInput.value = '';
|
fileInput.value = '';
|
||||||
analyzeBtn.disabled = true;
|
_updateAnalyzeButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
resetPromptBtn.addEventListener('click', () => {
|
resetPromptBtn.addEventListener('click', () => {
|
||||||
|
|
@ -1114,11 +1086,23 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.</textarea>
|
||||||
analyzeBtn.addEventListener('click', _analyzeDocument);
|
analyzeBtn.addEventListener('click', _analyzeDocument);
|
||||||
|
|
||||||
async function _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
|
// Prüfen ob Modell gewählt
|
||||||
if (!modelName.value) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1127,16 +1111,23 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.</textarea>
|
||||||
_hideError();
|
_hideError();
|
||||||
|
|
||||||
try {
|
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)
|
// Call Python backend API (has CORS enabled)
|
||||||
const response = await fetch('/api/analyze', {
|
const response = await fetch('/api/analyze', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(requestBody)
|
||||||
imageBase64: currentImageBase64,
|
|
||||||
prompt: promptInput.value,
|
|
||||||
ollamaUrl: ollamaUrl.value,
|
|
||||||
modelName: modelName.value
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
@ -1189,25 +1180,87 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.</textarea>
|
||||||
errorContainer.style.display = 'none';
|
errorContainer.style.display = 'none';
|
||||||
formContainer.style.display = 'block';
|
formContainer.style.display = 'block';
|
||||||
|
|
||||||
// Fill form fields
|
// Dynamische Felder Container
|
||||||
document.getElementById('field-haendler').value = data.haendler || '';
|
const dynamicFields = document.getElementById('dynamic-fields');
|
||||||
document.getElementById('field-datum').value = data.datum || '';
|
dynamicFields.innerHTML = '';
|
||||||
document.getElementById('field-betrag_brutto').value = data.betrag_brutto || '';
|
|
||||||
document.getElementById('field-betrag_netto').value = data.betrag_netto || '';
|
// Alle Keys aus dem JSON (Ebene 1) als Felder rendern
|
||||||
document.getElementById('field-mwst_betrag').value = data.mwst_betrag || '';
|
const keys = Object.keys(data);
|
||||||
document.getElementById('field-mwst_satz').value = data.mwst_satz || '';
|
|
||||||
document.getElementById('field-waehrung').value = data.waehrung || '';
|
// Felder in 2er-Reihen anordnen (ausser bei langen Werten)
|
||||||
document.getElementById('field-kategorie').value = data.kategorie || '';
|
let currentRow = null;
|
||||||
document.getElementById('field-zahlungsart').value = data.zahlungsart || '';
|
let fieldsInRow = 0;
|
||||||
document.getElementById('field-rechnungsnummer').value = data.rechnungsnummer || '';
|
|
||||||
document.getElementById('field-positionen').value =
|
keys.forEach((key, index) => {
|
||||||
Array.isArray(data.positionen) ? data.positionen.join('\n') : (data.positionen || '');
|
const value = data[key];
|
||||||
document.getElementById('field-notizen').value = data.notizen || '';
|
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
|
// Raw JSON
|
||||||
rawJson.textContent = JSON.stringify(data, null, 2);
|
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
|
// Toggle raw JSON
|
||||||
let rawVisible = false;
|
let rawVisible = false;
|
||||||
toggleRaw.addEventListener('click', () => {
|
toggleRaw.addEventListener('click', () => {
|
||||||
|
|
@ -1216,22 +1269,26 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.</textarea>
|
||||||
toggleRaw.querySelector('span:first-child').textContent = rawVisible ? '▼' : '▶';
|
toggleRaw.querySelector('span:first-child').textContent = rawVisible ? '▼' : '▶';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get current form data
|
// Get current form data (dynamisch aus allen Feldern)
|
||||||
function _getCurrentFormData() {
|
function _getCurrentFormData() {
|
||||||
return {
|
const data = {};
|
||||||
haendler: document.getElementById('field-haendler').value || null,
|
const dynamicFields = document.getElementById('dynamic-fields');
|
||||||
datum: document.getElementById('field-datum').value || null,
|
const inputs = dynamicFields.querySelectorAll('input, textarea');
|
||||||
betrag_brutto: document.getElementById('field-betrag_brutto').value || null,
|
|
||||||
betrag_netto: document.getElementById('field-betrag_netto').value || null,
|
inputs.forEach(input => {
|
||||||
mwst_betrag: document.getElementById('field-mwst_betrag').value || null,
|
// Key aus ID extrahieren (field-xxx -> xxx)
|
||||||
mwst_satz: document.getElementById('field-mwst_satz').value || null,
|
const key = input.id.replace('field-', '');
|
||||||
waehrung: document.getElementById('field-waehrung').value || null,
|
const value = input.value.trim();
|
||||||
kategorie: document.getElementById('field-kategorie').value || null,
|
|
||||||
zahlungsart: document.getElementById('field-zahlungsart').value || null,
|
// Mehrzeilige Werte als Array speichern
|
||||||
rechnungsnummer: document.getElementById('field-rechnungsnummer').value || null,
|
if (input.tagName === 'TEXTAREA' && value.includes('\n')) {
|
||||||
positionen: document.getElementById('field-positionen').value.split('\n').filter(p => p.trim()),
|
data[key] = value.split('\n').filter(line => line.trim());
|
||||||
notizen: document.getElementById('field-notizen').value || null
|
} else {
|
||||||
};
|
data[key] = value || null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy JSON
|
// Copy JSON
|
||||||
|
|
@ -1249,7 +1306,8 @@ Falls ein Feld nicht erkennbar ist, setze den Wert auf null.</textarea>
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
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();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue