initial
This commit is contained in:
parent
e46a01e8ae
commit
d257c3e753
8 changed files with 1735 additions and 0 deletions
319
app.py
Normal file
319
app.py
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
"""
|
||||||
|
Belegscanner - KI-Dokumentenanalyse
|
||||||
|
Python Flask Web App mit CORS-Unterstützung und Poweron Design
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Flask, render_template, request, jsonify
|
||||||
|
from flask_cors import CORS
|
||||||
|
import requests
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import io
|
||||||
|
|
||||||
|
# PDF Support
|
||||||
|
try:
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
PDF_SUPPORT = True
|
||||||
|
except ImportError:
|
||||||
|
PDF_SUPPORT = False
|
||||||
|
print("WARNUNG: PyMuPDF nicht installiert. PDF-Support deaktiviert.")
|
||||||
|
print("Installieren mit: pip install pymupdf")
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
CORS(app) # CORS für alle Routen aktivieren
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PDF Helper Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def _extractImagesFromPdf(pdfBytes, maxPages=5):
|
||||||
|
"""
|
||||||
|
Extrahiert Bilder aus einem PDF.
|
||||||
|
Gibt eine Liste von Base64-kodierten Bildern zurück.
|
||||||
|
"""
|
||||||
|
if not PDF_SUPPORT:
|
||||||
|
raise Exception("PDF-Support nicht verfügbar. Bitte PyMuPDF installieren.")
|
||||||
|
|
||||||
|
images = []
|
||||||
|
|
||||||
|
# PDF öffnen
|
||||||
|
doc = fitz.open(stream=pdfBytes, filetype="pdf")
|
||||||
|
|
||||||
|
# Anzahl der Seiten begrenzen
|
||||||
|
numPages = min(len(doc), maxPages)
|
||||||
|
|
||||||
|
for pageNum in range(numPages):
|
||||||
|
page = doc[pageNum]
|
||||||
|
|
||||||
|
# Seite als Bild rendern (höhere Auflösung für bessere OCR)
|
||||||
|
mat = fitz.Matrix(2.0, 2.0) # 2x Zoom für bessere Qualität
|
||||||
|
pix = page.get_pixmap(matrix=mat)
|
||||||
|
|
||||||
|
# In PNG konvertieren
|
||||||
|
imgBytes = pix.tobytes("png")
|
||||||
|
imgBase64 = base64.b64encode(imgBytes).decode('utf-8')
|
||||||
|
|
||||||
|
images.append({
|
||||||
|
'page': pageNum + 1,
|
||||||
|
'base64': imgBase64,
|
||||||
|
'width': pix.width,
|
||||||
|
'height': pix.height
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
def _renderPdfPageAsImage(pdfBytes, pageNum=0, zoom=2.0):
|
||||||
|
"""
|
||||||
|
Rendert eine einzelne PDF-Seite als Bild.
|
||||||
|
"""
|
||||||
|
if not PDF_SUPPORT:
|
||||||
|
raise Exception("PDF-Support nicht verfügbar.")
|
||||||
|
|
||||||
|
doc = fitz.open(stream=pdfBytes, filetype="pdf")
|
||||||
|
|
||||||
|
if pageNum >= len(doc):
|
||||||
|
pageNum = len(doc) - 1
|
||||||
|
|
||||||
|
page = doc[pageNum]
|
||||||
|
mat = fitz.Matrix(zoom, zoom)
|
||||||
|
pix = page.get_pixmap(matrix=mat)
|
||||||
|
|
||||||
|
imgBytes = pix.tobytes("png")
|
||||||
|
imgBase64 = base64.b64encode(imgBytes).decode('utf-8')
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'base64': imgBase64,
|
||||||
|
'width': pix.width,
|
||||||
|
'height': pix.height,
|
||||||
|
'page': pageNum + 1,
|
||||||
|
'totalPages': len(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
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
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def _index():
|
||||||
|
"""Hauptseite mit dem Belegscanner UI"""
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/analyze', methods=['POST'])
|
||||||
|
def _analyzeDocument():
|
||||||
|
"""
|
||||||
|
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()
|
||||||
|
|
||||||
|
imageBase64 = data.get('imageBase64')
|
||||||
|
prompt = data.get('prompt')
|
||||||
|
ollamaUrl = data.get('ollamaUrl', 'http://localhost:11434')
|
||||||
|
modelName = data.get('modelName', 'qwen2.5vl:72b')
|
||||||
|
|
||||||
|
# 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=requestBody,
|
||||||
|
timeout=3600 # 60 Minuten
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
return jsonify({
|
||||||
|
'error': f'Modell "{modelName}" nicht gefunden. Bitte installieren Sie es mit: ollama pull {modelName}'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
return jsonify({
|
||||||
|
'error': f'Ollama API Fehler: {response.status_code} - {response.text[:200]}'
|
||||||
|
}), response.status_code
|
||||||
|
|
||||||
|
responseData = response.json()
|
||||||
|
responseText = responseData.get('response', '')
|
||||||
|
|
||||||
|
# Versuche JSON aus der Antwort zu extrahieren
|
||||||
|
extractedData = None
|
||||||
|
jsonMatch = re.search(r'\{[\s\S]*\}', responseText)
|
||||||
|
|
||||||
|
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,
|
||||||
|
'data': extractedData,
|
||||||
|
'rawResponse': responseText
|
||||||
|
})
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
return jsonify({'error': 'Zeitüberschreitung bei der Ollama API'}), 504
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
return jsonify({'error': 'Verbindung zu Ollama fehlgeschlagen. Ist Ollama gestartet?'}), 503
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
return jsonify({'error': f'JSON Parse-Fehler: {str(e)}'}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': f'Unerwarteter Fehler: {str(e)}'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/health', methods=['GET'])
|
||||||
|
def _healthCheck():
|
||||||
|
"""Health Check Endpoint"""
|
||||||
|
return jsonify({'status': 'ok', 'service': 'belegscanner', 'pdfSupport': PDF_SUPPORT})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/pdf/extract', methods=['POST'])
|
||||||
|
def _extractPdfImages():
|
||||||
|
"""
|
||||||
|
Extrahiert Bilder aus einem PDF.
|
||||||
|
Erwartet: { pdfBase64, page (optional, default: alle) }
|
||||||
|
"""
|
||||||
|
if not PDF_SUPPORT:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'PDF-Support nicht verfügbar. Bitte PyMuPDF installieren: pip install pymupdf'
|
||||||
|
}), 501
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
pdfBase64 = data.get('pdfBase64')
|
||||||
|
pageNum = data.get('page') # Optional: spezifische Seite
|
||||||
|
|
||||||
|
if not pdfBase64:
|
||||||
|
return jsonify({'error': 'Kein PDF übermittelt'}), 400
|
||||||
|
|
||||||
|
# Base64 dekodieren
|
||||||
|
pdfBytes = base64.b64decode(pdfBase64)
|
||||||
|
|
||||||
|
if pageNum is not None:
|
||||||
|
# Einzelne Seite extrahieren
|
||||||
|
result = _renderPdfPageAsImage(pdfBytes, pageNum - 1) # 0-basiert
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'image': result
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Alle Seiten extrahieren (max 5)
|
||||||
|
images = _extractImagesFromPdf(pdfBytes, maxPages=5)
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'images': images,
|
||||||
|
'totalExtracted': len(images)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': f'PDF-Verarbeitungsfehler: {str(e)}'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/ollama/status', methods=['GET'])
|
||||||
|
def _ollamaStatus():
|
||||||
|
"""Prüft ob Ollama erreichbar ist und listet verfügbare Modelle"""
|
||||||
|
ollamaUrl = request.args.get('url', 'http://localhost:11434')
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Prüfe ob Ollama läuft
|
||||||
|
response = requests.get(f'{ollamaUrl}/api/tags', timeout=5)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
return jsonify({
|
||||||
|
'connected': False,
|
||||||
|
'error': f'Ollama antwortet mit Status {response.status_code}'
|
||||||
|
})
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
models = [m.get('name', '') for m in data.get('models', [])]
|
||||||
|
|
||||||
|
# Filtere Vision-Modelle (enthalten oft 'vision', 'vl', 'llava' im Namen)
|
||||||
|
visionModels = [m for m in models if any(x in m.lower() for x in ['vision', 'vl', 'llava', 'bakllava'])]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'connected': True,
|
||||||
|
'models': models,
|
||||||
|
'visionModels': visionModels,
|
||||||
|
'totalModels': len(models)
|
||||||
|
})
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
return jsonify({
|
||||||
|
'connected': False,
|
||||||
|
'error': 'Keine Verbindung zu Ollama. Ist Ollama gestartet?'
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'connected': False,
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Main
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print(" Belegscanner - KI-Dokumentenanalyse")
|
||||||
|
print(" Powered by Poweron")
|
||||||
|
print("="*60)
|
||||||
|
print("\n Server läuft auf: http://localhost:5000")
|
||||||
|
print(" CORS ist aktiviert für alle Origins")
|
||||||
|
print("\n Drücke Ctrl+C zum Beenden")
|
||||||
|
print("="*60 + "\n")
|
||||||
|
|
||||||
|
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
flask>=3.0.0
|
||||||
|
flask-cors>=4.0.0
|
||||||
|
requests>=2.31.0
|
||||||
|
werkzeug>=3.0.0
|
||||||
|
pymupdf>=1.24.0
|
||||||
41
start-python.bat
Normal file
41
start-python.bat
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
echo ============================================================
|
||||||
|
echo Belegscanner - KI-Dokumentenanalyse
|
||||||
|
echo Powered by Poweron
|
||||||
|
echo ============================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Ollama starten mit CORS-Freigabe
|
||||||
|
echo [1/3] Starte Ollama mit CORS-Freigabe...
|
||||||
|
set OLLAMA_ORIGINS=*
|
||||||
|
|
||||||
|
REM Versuche Ollama zu finden
|
||||||
|
where ollama >nul 2>&1
|
||||||
|
if %errorlevel%==0 (
|
||||||
|
start /min ollama serve
|
||||||
|
echo Ollama gestartet
|
||||||
|
) else (
|
||||||
|
if exist "%LOCALAPPDATA%\Programs\Ollama\ollama.exe" (
|
||||||
|
start /min "" "%LOCALAPPDATA%\Programs\Ollama\ollama.exe" serve
|
||||||
|
echo Ollama gestartet
|
||||||
|
) else (
|
||||||
|
echo Ollama nicht gefunden - bitte manuell starten!
|
||||||
|
echo Setze OLLAMA_ORIGINS=* vor dem Start
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
timeout /t 2 /nobreak > nul
|
||||||
|
|
||||||
|
REM Dependencies installieren
|
||||||
|
echo [2/3] Installiere Python Dependencies...
|
||||||
|
pip install -r requirements.txt --quiet
|
||||||
|
|
||||||
|
echo [3/3] Starte Python Flask Server...
|
||||||
|
echo.
|
||||||
|
echo Server URL: http://localhost:5000
|
||||||
|
echo Druecke Ctrl+C zum Beenden
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Flask starten
|
||||||
|
python app.py
|
||||||
54
start-python.ps1
Normal file
54
start-python.ps1
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Belegscanner - Python Web App Starter
|
||||||
|
# Poweron Design
|
||||||
|
|
||||||
|
Write-Host "============================================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " Belegscanner - KI-Dokumentenanalyse" -ForegroundColor White
|
||||||
|
Write-Host " Powered by Poweron" -ForegroundColor Magenta
|
||||||
|
Write-Host "============================================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Ollama mit CORS starten (optional)
|
||||||
|
Write-Host "[1/3] Starte Ollama mit CORS-Freigabe..." -ForegroundColor Yellow
|
||||||
|
$env:OLLAMA_ORIGINS = "*"
|
||||||
|
|
||||||
|
# Versuche Ollama zu finden und zu starten
|
||||||
|
$ollamaPath = Get-Command ollama -ErrorAction SilentlyContinue
|
||||||
|
if ($ollamaPath) {
|
||||||
|
Start-Process -FilePath $ollamaPath.Source -ArgumentList "serve" -WindowStyle Minimized
|
||||||
|
Write-Host " Ollama gestartet" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
# Fallback: Standard-Installationspfade pruefen
|
||||||
|
$defaultPaths = @(
|
||||||
|
"$env:LOCALAPPDATA\Programs\Ollama\ollama.exe",
|
||||||
|
"$env:ProgramFiles\Ollama\ollama.exe",
|
||||||
|
"C:\Users\$env:USERNAME\AppData\Local\Programs\Ollama\ollama.exe"
|
||||||
|
)
|
||||||
|
$found = $false
|
||||||
|
foreach ($path in $defaultPaths) {
|
||||||
|
if (Test-Path $path) {
|
||||||
|
Start-Process -FilePath $path -ArgumentList "serve" -WindowStyle Minimized
|
||||||
|
Write-Host " Ollama gestartet von: $path" -ForegroundColor Green
|
||||||
|
$found = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $found) {
|
||||||
|
Write-Host " Ollama nicht gefunden - bitte manuell starten!" -ForegroundColor Red
|
||||||
|
Write-Host " Setze OLLAMA_ORIGINS=* vor dem Start" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
|
||||||
|
# Dependencies installieren
|
||||||
|
Write-Host "[2/3] Installiere Python Dependencies..." -ForegroundColor Yellow
|
||||||
|
pip install -r requirements.txt --quiet
|
||||||
|
|
||||||
|
Write-Host "[3/3] Starte Flask Server..." -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Server URL: http://localhost:5000" -ForegroundColor Green
|
||||||
|
Write-Host "Druecke Ctrl+C zum Beenden" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Flask Server starten
|
||||||
|
python app.py
|
||||||
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
static/poweron-logo.png
Normal file
BIN
static/poweron-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
t1.png
Normal file
BIN
t1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
1316
templates/index.html
Normal file
1316
templates/index.html
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue