4065 lines
85 KiB
Plaintext
4065 lines
85 KiB
Plaintext
Die Frage ist, heben wir die den Scanner in die Umgebung oder Mappen wir einen Ordner in das Reposotory. Vielleicht machen wir es auch über die Ordnerstruktur. Manche Dokumente werden an verschiedenen Stellen benötigt, Wohngeld Brauche ich für die Übersicht aber auch für die Nebenkosten und die Steuer.
|
||
Um das Elster Formular besser vorzubereiten wäre eine Auswertung des letzten Dokumente hilfreich. Aber damit das vernünftig ausgewertet werden kann, wird OCR nicht reichen, auch hier wird KI benötigt. Mein Vorschlag, wir nutzen auch hier meine ollama Instanz und jagen die Daten mit falschem werten in die Modelle und dann korrigiert in unsere Applikation..was sagst du zu den vorgehen?
|
||
|
||
profile
|
||
kimi-k2.5:cloud
|
||
Nachgedacht für 11 Sekunden
|
||
Das ist ein exzellenter architektonischer Ansatz! Die Kombination aus ordner-basiertem Ingest, graphenartiger Dokumentenverwendung und Ollama für Kontext-verstärkte Extraktion löst genau die Probleme, die kommerzielle Tools haben (starre Workflows & Cloud-Abhängigkeit).
|
||
|
||
🏗️ Architektur-Vorschlag: "Smart Document Graph"
|
||
1. Scan-Strategie: Ordner-Mapping (Hot Folder)
|
||
Nicht den Scanner direkt integrieren, sondern einen Watchdog-Ordner:
|
||
|
||
yaml
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
# docker-compose.yml Erweiterung
|
||
services:
|
||
scanner-watchdog:
|
||
image: python:3.11-slim
|
||
volumes:
|
||
- ./scan-inbox:/app/inbox:ro # Netzwerk-Scanordner (SMB/NFS)
|
||
- ./scan-processing:/app/processing # Temporär
|
||
- ./scan-archive:/app/archive # Dauerhaft (Original)
|
||
environment:
|
||
- OLLAMA_URL=http://ollama:11434
|
||
- DOCUMENT_API=http://app:3000/api/documents
|
||
command: python watchdog.py
|
||
|
||
ollama:
|
||
image: ollama/ollama:latest
|
||
container_name: buchhaltung_ollama
|
||
volumes:
|
||
- ollama_data:/root/.ollama
|
||
- ./ollama-modelfile:/modelfile # Für Custom Modelle
|
||
deploy:
|
||
resources:
|
||
limits:
|
||
memory: 8G # Wichtig für LLMs!
|
||
networks:
|
||
- app-network
|
||
Warum Ordner-Mapping?
|
||
|
||
Der Scanner speichert per SMB/FTP in ./scan-inbox
|
||
Mehrere Quellen: Scanner, E-Mail-Attachments (via IMAP), manueller Upload
|
||
Idempotenz: Datei-Hash prüfen, Duplikate vermeiden
|
||
2. Dokumenten-Modell: "Single Source of Truth" mit Multi-Context
|
||
Ein Dokument existiert physikalisch einmal, wird aber logisch mehrfach referenziert:
|
||
|
||
sql
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
⌄
|
||
⌄
|
||
-- Dokumente (Single Source)
|
||
CREATE TABLE documents (
|
||
id UUID PRIMARY KEY,
|
||
file_path VARCHAR(500) UNIQUE, -- z.B. "archive/2024/scan_001.pdf"
|
||
file_hash VARCHAR(64), -- SHA256 für Duplikat-Erkennung
|
||
ocr_raw TEXT, -- Roh-OCR
|
||
extracted_json JSONB, -- Ollama-Extraktion
|
||
extraction_version INTEGER, -- Für Iterationen
|
||
created_at TIMESTAMP
|
||
);
|
||
|
||
-- Dokument-Context (Nutzung an verschiedenen Stellen)
|
||
CREATE TABLE document_usages (
|
||
id UUID PRIMARY KEY,
|
||
document_id UUID REFERENCES documents(id),
|
||
context_type VARCHAR(50), -- 'wohngeld', 'euer', 'steuer', 'privat'
|
||
context_id UUID, -- Foreign Key zur jeweiligen Tabelle
|
||
extracted_subset JSONB, -- Nur relevante Felder für diesen Context
|
||
confidence FLOAT,
|
||
verified BOOLEAN DEFAULT false,
|
||
corrected_data JSONB, -- User-Korrekturen (für Training)
|
||
created_at TIMESTAMP
|
||
);
|
||
|
||
-- Beispiel: Eine Wohngeldabrechnung
|
||
-- documents: Das gescannte PDF
|
||
-- document_usages:
|
||
-- 1. context='wohngeld', context_id=[mietverhaeltnis_id]
|
||
-- 2. context='euer', context_id=[geschaeftsjahr_id]
|
||
-- 3. context='steuer', context_id=[steuerjahr_id]
|
||
3. Ollama-Integration: "Falsch-Values" Workflow (Active Learning)
|
||
Dein Ansatz ist genial – das nennt man "Human-in-the-Loop" mit Few-Shot Learning:
|
||
|
||
python
|
||
|
||
Einklappen
|
||
|
||
Ausführen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
32
|
||
33
|
||
34
|
||
35
|
||
36
|
||
37
|
||
38
|
||
39
|
||
40
|
||
41
|
||
42
|
||
43
|
||
44
|
||
45
|
||
46
|
||
47
|
||
48
|
||
49
|
||
50
|
||
51
|
||
52
|
||
53
|
||
54
|
||
55
|
||
56
|
||
57
|
||
58
|
||
59
|
||
60
|
||
61
|
||
62
|
||
63
|
||
64
|
||
65
|
||
66
|
||
67
|
||
68
|
||
69
|
||
70
|
||
71
|
||
72
|
||
73
|
||
74
|
||
75
|
||
76
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
# ollama_processor.py
|
||
import ollama
|
||
import json
|
||
from typing import Dict, Optional
|
||
|
||
class DocumentIntelligence:
|
||
def __init__(self):
|
||
self.model = "mixtral" # oder "llama3", "mistral", "codellama"
|
||
# Für deutsche Finanzdokumente evtl. "dolphin-mixtral"
|
||
|
||
def extract_with_history(self, ocr_text: str, document_type: str,
|
||
previous_corrections: Optional[list] = None) -> Dict:
|
||
"""
|
||
Nutzt vorherige Korrekturen für bessere Extraktion
|
||
"""
|
||
|
||
# Few-Shot Beispiele aus corrected_data laden
|
||
examples = ""
|
||
if previous_corrections:
|
||
for corr in previous_corrections[-3:]: # Letzte 3 Korrekturen
|
||
examples += f"""
|
||
Beispiel:
|
||
Input: {corr['raw_text']}
|
||
Extrahiert: {json.dumps(corr['wrong_extraction'])}
|
||
Korrektur: {json.dumps(corr['corrected'])}
|
||
"""
|
||
|
||
prompt = f"""Du bist ein deutscher Steuerberater-Assistent.
|
||
Analysiere folgenden OCR-Text eines {document_type}-Dokuments.
|
||
|
||
{examples}
|
||
|
||
Extrahiere folgende Felder als JSON:
|
||
- betrag_gesamt (Zahl)
|
||
- datum (ISO 8601)
|
||
- kostenstelle (Kategorie: Immobilie, Hausmeister, Finanzierung, Versicherung)
|
||
- steuerlich_absetzbar (boolean)
|
||
- mwst_satz (7 oder 19 oder 0)
|
||
- zahlungsziel (Tage oder null)
|
||
|
||
WICHTIG: Wenn du unsicher bist, setze den Wert auf null oder "unbekannt".
|
||
|
||
OCR-Text:
|
||
{ocr_text}
|
||
|
||
Antwort nur als JSON:"""
|
||
|
||
response = ollama.generate(model=self.model, prompt=prompt)
|
||
|
||
try:
|
||
return json.loads(response['response'])
|
||
except:
|
||
return {"error": "parse_failed", "raw": response['response']}
|
||
|
||
def validate_against_history(self, current_doc: Dict,
|
||
historical_docs: list) -> Dict:
|
||
"""
|
||
Plausibilitätscheck: "Grundsteuer um 20% gestiegen?"
|
||
"""
|
||
if not historical_docs:
|
||
return {"warnings": []}
|
||
|
||
prompt = f"""Vergleiche dieses Dokument mit historischen Daten:
|
||
|
||
Aktuell: {json.dumps(current_doc)}
|
||
Historie (letzte 3): {json.dumps(historical_docs)}
|
||
|
||
Gib Warnungen aus als JSON-Array:
|
||
- auffaellige_abweichungen (z.B. "Kosten um 50% gestiegen")
|
||
- fehlende_felder (was fehlt vs. letztes Jahr)
|
||
- empfohlene_kostenstelle (basierend auf Ähnlichkeit)
|
||
|
||
Antwort als JSON:"""
|
||
|
||
response = ollama.generate(model=self.model, prompt=prompt)
|
||
return json.loads(response['response'])
|
||
4. Der Korrektur-Workflow (Feedback-Loop)
|
||
typescript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
32
|
||
33
|
||
34
|
||
35
|
||
36
|
||
37
|
||
38
|
||
39
|
||
40
|
||
41
|
||
42
|
||
43
|
||
44
|
||
45
|
||
46
|
||
47
|
||
48
|
||
49
|
||
50
|
||
51
|
||
52
|
||
53
|
||
54
|
||
55
|
||
56
|
||
57
|
||
58
|
||
59
|
||
60
|
||
61
|
||
62
|
||
63
|
||
64
|
||
65
|
||
66
|
||
67
|
||
68
|
||
69
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
// Frontend-Komponente: "Smart Correction Interface"
|
||
interface DocumentCorrectionProps {
|
||
documentId: string;
|
||
initialExtraction: any; // Ollama's "falsche" Werte
|
||
documentType: string;
|
||
}
|
||
|
||
const SmartCorrection: React.FC<DocumentCorrectionProps> = ({
|
||
documentId,
|
||
initialExtraction
|
||
}) => {
|
||
const [values, setValues] = useState(initialExtraction);
|
||
const [isCorrecting, setIsCorrecting] = useState(false);
|
||
|
||
const handleCorrection = async (field: string, correctValue: any) => {
|
||
const oldValue = values[field];
|
||
|
||
// Speichere Korrektur für Ollama-Lernen
|
||
await fetch('/api/ml/feedback', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
documentId,
|
||
field,
|
||
wrongValue: oldValue,
|
||
correctValue,
|
||
context: values, // Voller Kontext
|
||
ocrSnippet: getRelevantOCR(field) // Textausschnitt
|
||
})
|
||
});
|
||
|
||
// Aktualisiere UI
|
||
setValues({...values, [field]: correctValue});
|
||
};
|
||
|
||
// "Rot markiert" = Confidence < 0.7 oder null
|
||
const getFieldColor = (field: string) => {
|
||
const val = values[field];
|
||
if (val === null || val === "unbekannt") return "border-red-500 bg-red-50";
|
||
if (initialExtraction.confidence?.[field] < 0.7) return "border-yellow-400";
|
||
return "border-green-500";
|
||
};
|
||
|
||
return (
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="document-preview">
|
||
<img src={`/api/documents/${documentId}/preview`} />
|
||
{/* OCR-Highlighting beim Hover über Feld */}
|
||
</div>
|
||
|
||
<div className="extraction-form">
|
||
{Object.entries(values).map(([key, val]) => (
|
||
<div key={key} className={`field ${getFieldColor(key)}`}>
|
||
<label>{key}</label>
|
||
<input
|
||
value={val || ""}
|
||
onChange={(e) => handleCorrection(key, e.target.value)}
|
||
className={val === null ? "text-red-600 font-bold" : ""}
|
||
/>
|
||
{val === null && <span className="error-badge">Fehlt!</span>}
|
||
</div>
|
||
))}
|
||
|
||
<button onClick={() => propagateToContexts(documentId, values)}>
|
||
Für alle Kontexte übernehmen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
5. Kontext-Sensitive Verwendung
|
||
Wenn du das Wohngeld-Dokument in die Steuer übernimmst:
|
||
|
||
python
|
||
|
||
Einklappen
|
||
|
||
Ausführen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
32
|
||
33
|
||
⌄
|
||
⌄
|
||
⌄
|
||
def propagate_document_context(document_id: str, source_context: str, target_context: str):
|
||
"""
|
||
Überträgt Dokumentendaten zwischen Kontexten mit Ollama-Transformation
|
||
"""
|
||
|
||
doc = get_document(document_id)
|
||
source_data = doc['usages'][source_context]['extracted_subset']
|
||
|
||
# Ollama transformiert Wohngeld-Daten in Steuer-relevante Daten
|
||
prompt = f"""
|
||
Transformiere diese Nebenkostenabrechnungs-Daten in Steuerliche Kostenstellen (EÜR).
|
||
|
||
Input (Wohngeld):
|
||
{json.dumps(source_data)}
|
||
|
||
Regeln:
|
||
- Heizkosten → Heizkosten (Steuerlich absetzbar bei Vermietung)
|
||
- Grundsteuer → Grundsteuer
|
||
- Versicherungen → Versicherungskosten
|
||
- Hausmeister → Instandhaltungskosten (sofern nicht unterhaltend)
|
||
|
||
Output als JSON mit Feldern: konto_skr03, betrag, absetzbar_ja_nein, anmerkung
|
||
"""
|
||
|
||
transformation = ollama.generate(model='mixtral', prompt=prompt)
|
||
|
||
# Speichere in neuem Context
|
||
create_document_usage(
|
||
document_id=document_id,
|
||
context_type=target_context,
|
||
extracted_subset=json.loads(transformation),
|
||
inherited_from=source_context
|
||
)
|
||
6. Elster-Vorbereitung mit "Memory"
|
||
Für die Elster-Auswertung lädst du die letzten Dokumente in den Kontext:
|
||
|
||
python
|
||
|
||
Einklappen
|
||
|
||
Ausführen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
⌄
|
||
⌄
|
||
def prepare_elster_summary(year: int):
|
||
# Hole alle Steuer-relevanten Dokumente des Jahres
|
||
docs = get_documents_by_context('steuer', year)
|
||
|
||
# Gruppiere nach Kategorien
|
||
summary_prompt = f"""
|
||
Basierend auf folgenden Dokumenten des Jahres {year}, erstelle eine Elster-Vorlage.
|
||
|
||
Dokumente:
|
||
{json.dumps([d['extracted_subset'] for d in docs])}
|
||
|
||
Erstelle:
|
||
1. EÜR-Vorlage (Einnahmen-Überschuss-Rechnung)
|
||
2. Anlage AUS (Ausländische Einkünfte) falls relevant
|
||
3. Anlage EÜR (Betriebseinnahmen/-ausgaben)
|
||
|
||
Markiere Unstimmigkeiten und fehlende Belege rot.
|
||
"""
|
||
|
||
return ollama.generate(model='mixtral', prompt=summary_prompt)
|
||
📁 Ordner-Struktur Vorschlag
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
./data
|
||
├── /scan-inbox # Temporär (Watchdog verarbeitet)
|
||
├── /scan-processing # Aktive OCR-Verarbeitung
|
||
├── /archive # Original-PDFs (Jahres-Struktur)
|
||
│ ├── 2023/
|
||
│ ├── 2024/
|
||
│ └── ...
|
||
├── /extracted # JSON-Exports (Backup)
|
||
│ ├── ollama-raw/ # Roh-Extraktionen
|
||
│ └── corrected/ # Korrigierte Versionen
|
||
└── /training-data # Für Ollama-Fine-Tuning
|
||
└── corrections.jsonl # Deine Korrekturen als Training
|
||
⚠️ Wichtige Hinweise
|
||
Ollama Performance:
|
||
Mixtral braucht mindestens 6-8GB RAM
|
||
Nutze OLLAMA_KEEP_ALIVE=24h damit das Modell im Speicher bleibt
|
||
Für schnelle Tests: llama3 (weniger präzise, aber schneller)
|
||
Feedback-Loop persistieren:
|
||
json
|
||
|
||
Einklappen
|
||
Kopieren
|
||
1
|
||
2
|
||
// corrections.jsonl Format für späteres Training
|
||
{"prompt": "OCR: Heizkstr 1.234,50\nFelder:", "completion": "{\"heizkosten\": 1234.50}"}
|
||
Versionierung:
|
||
Speichere jeden Extraktions-Versuch (extraction_version)
|
||
Ollama lernt daraus, welche Versionen besser waren
|
||
Backup-Strategie:
|
||
Dokumente sind unveränderlich (immutable)
|
||
Nur die extracted_json und document_usages ändern sich
|
||
Soll ich die Watchdog-Implementierung (Python-Skript für Ordner-Überwachung) oder das Ollama-Prompt-Engineering für deutsche Finanzdokumente detailliert ausarbeiten?
|
||
|
||
Dass olama läuft zwar lokal, aber es sind auch online Modelle dahinter. Lass uns daher die Zahlen vorher durch Platzhalter oder falsche Werte ersetzen, genau wie Namen und Anschrift. Die url ist ollama.lan:11434
|
||
|
||
Durch die Namensauflösung kann diese URL auch in anderen Umgebungen oder beim Verschieben des Container genutzt werden. Als Modell nutze ich aktuell überwiegend kimi2.5
|
||
|
||
Wenn wir ein lokales Modell benötigen kann favorisiere ich aktuell glm-4.7
|
||
|
||
profile
|
||
kimi-k2.5:cloud
|
||
Nachgedacht für 7 Sekunden
|
||
Ausgezeichnete Security-Herangehensweise! Ein Privacy Gateway mit Pre/Post-Processing ist genau der richtige Ansatz für sensible Finanzdaten. Hier ist die Architektur für eine anonymisierte KI-Verarbeitung:
|
||
|
||
🔒 Privacy-First Architektur
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
Scan → [Anonymisierungs-Engine] → Ollama (kimi2.5/glm-4.7) → [Re-Identifizierung] → DB
|
||
↓ ↓
|
||
Mapping-Tabelle Anonymisierte
|
||
(Redis/Postgres) Extraktion
|
||
1. Anonymisierungs-Engine (Pre-Processing)
|
||
Wir ersetzen Entities konsistent (über das gesamte Dokument gleiche Fake-Werte):
|
||
|
||
python
|
||
|
||
Einklappen
|
||
|
||
Ausführen
|
||
Speichern
|
||
Kopieren
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
32
|
||
33
|
||
34
|
||
35
|
||
36
|
||
37
|
||
38
|
||
39
|
||
40
|
||
41
|
||
42
|
||
43
|
||
44
|
||
45
|
||
46
|
||
47
|
||
48
|
||
49
|
||
50
|
||
51
|
||
52
|
||
53
|
||
54
|
||
55
|
||
56
|
||
57
|
||
58
|
||
59
|
||
60
|
||
61
|
||
62
|
||
63
|
||
64
|
||
65
|
||
66
|
||
67
|
||
68
|
||
69
|
||
70
|
||
71
|
||
72
|
||
73
|
||
74
|
||
75
|
||
76
|
||
77
|
||
78
|
||
79
|
||
80
|
||
81
|
||
82
|
||
83
|
||
84
|
||
85
|
||
86
|
||
87
|
||
88
|
||
89
|
||
90
|
||
91
|
||
92
|
||
93
|
||
94
|
||
95
|
||
96
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
# privacy_guard.py
|
||
mapping[placeholder] = {"type": "name", "original": name, "hash": self._hash(name)}
|
||
self._store_mapping(placeholder, name)
|
||
|
||
# 2. Adressen (Straßen, PLZ, Orte)
|
||
address_pattern = r'([A-Z][a-zäöüß]+(?:straße|str\.|weg|platz)\s+\d+\s*,?\s*\d{5}\s+[A-Z][a-zäöüß]+)'
|
||
addresses = re.findall(address_pattern, text, re.IGNORECASE)
|
||
for i, addr in enumerate(set(addresses)):
|
||
placeholder = f"[ADRESSE_{i}]"
|
||
text = text.replace(addr, placeholder)
|
||
mapping[placeholder] = {"type": "address", "original": addr}
|
||
self._store_mapping(placeholder, addr)
|
||
|
||
# 3. IBANs (DE\d{20})
|
||
iban_pattern = r'(DE\d{2}\s?(?:\d{4}\s?){4}\d{2})'
|
||
ibans = re.findall(iban_pattern, text)
|
||
for i, iban in enumerate(set(ibans)):
|
||
fake_iban = f"DE99{str(i).zfill(2)}99999999999999" # Fake aber valide Struktur
|
||
text = text.replace(iban, fake_iban)
|
||
mapping[fake_iban] = {"type": "iban", "original": iban.replace(" ", "")}
|
||
self._store_mapping(fake_iban, iban)
|
||
|
||
# 4. Zahlen (Beträge) - Konsistente Pseudonymisierung
|
||
# Wichtig: Struktur erhalten (Dezimalstellen, Trennzeichen)
|
||
amount_pattern = r'(\d{1,3}(?:\.\d{3})*,\d{2})' # deutsches Format
|
||
|
||
def replace_amount(match):
|
||
original = match.group(1)
|
||
# Deterministische Pseudonymisierung (gleicher Betrag = gleicher Fake)
|
||
fake_amount = self._pseudo_number(original)
|
||
mapping[fake_amount] = {"type": "amount", "original": original}
|
||
self._store_mapping(fake_amount, original)
|
||
return fake_amount
|
||
|
||
text = re.sub(amount_pattern, replace_amount, text)
|
||
|
||
# 5. Datum - Verschiebung um fixe Tage (z.B. +100 Tage)
|
||
date_pattern = r'(\d{2}\.\d{2}\.\d{4})'
|
||
dates = re.findall(date_pattern, text)
|
||
for date in set(dates):
|
||
fake_date = self._shift_date(date, days=100)
|
||
text = text.replace(date, fake_date)
|
||
mapping[fake_date] = {"type": "date", "original": date}
|
||
self._store_mapping(fake_date, date)
|
||
|
||
return text, mapping
|
||
|
||
def _pseudo_number(self, original: str) -> str:
|
||
"""Deterministische Fake-Zahl generieren"""
|
||
# Hash-basiert, damit gleiche Beträge gleiche Fakes ergeben
|
||
h = hashlib.md5(original.encode()).hexdigest()
|
||
# Erste 4 Ziffern als Seed
|
||
num = int(h[:4], 16)
|
||
# Realistischer Betrag zwischen 10,00 und 9999,99
|
||
fake = (num % 9990) + 10
|
||
return f"{fake},00" # oder mit originaler Dezimalstelle
|
||
|
||
def _shift_date(self, date_str: str, days: int) -> str:
|
||
"""Datum verschieben um X Tage"""
|
||
from datetime import datetime, timedelta
|
||
d = datetime.strptime(date_str, "%d.%m.%Y")
|
||
d += timedelta(days=days)
|
||
return d.strftime("%d.%m.%Y")
|
||
|
||
def _hash(self, value: str) -> str:
|
||
return hashlib.sha256(value.encode()).hexdigest()[:8]
|
||
|
||
def _store_mapping(self, fake: str, original: str):
|
||
"""Speichere Mapping für 24h (oder persistenter)"""
|
||
self.redis.hset(self.session_id, fake, original)
|
||
self.redis.expire(self.session_id, 86400) # 24h TTL
|
||
2. Ollama-Client mit Fallback
|
||
python
|
||
|
||
Einklappen
|
||
|
||
Ausführen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
32
|
||
33
|
||
34
|
||
35
|
||
36
|
||
37
|
||
38
|
||
39
|
||
40
|
||
41
|
||
42
|
||
43
|
||
44
|
||
45
|
||
46
|
||
47
|
||
48
|
||
49
|
||
50
|
||
51
|
||
52
|
||
53
|
||
54
|
||
55
|
||
56
|
||
57
|
||
58
|
||
59
|
||
60
|
||
61
|
||
62
|
||
63
|
||
64
|
||
65
|
||
66
|
||
67
|
||
68
|
||
69
|
||
70
|
||
71
|
||
72
|
||
73
|
||
74
|
||
75
|
||
76
|
||
77
|
||
78
|
||
79
|
||
80
|
||
81
|
||
82
|
||
83
|
||
84
|
||
85
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
# ollama_client.py
|
||
import ollama
|
||
import requests
|
||
from typing import Optional
|
||
|
||
class SecureOllamaClient:
|
||
def __init__(self, base_url: str = "http://ollama.lan:11434"):
|
||
self.base_url = base_url
|
||
self.primary_model = "kimi2.5" # Externes Modell
|
||
self.fallback_model = "glm-4.7" # Lokales Fallback
|
||
|
||
def extract_financial_data(self, anonymized_text: str, doc_type: str):
|
||
"""
|
||
Versucht zuerst kimi2.5, fallback zu glm-4.7 bei Fehler
|
||
"""
|
||
|
||
prompt = self._build_prompt(anonymized_text, doc_type)
|
||
|
||
# Versuch 1: Online-Modell (kimi2.5)
|
||
try:
|
||
response = self._generate(
|
||
model=self.primary_model,
|
||
prompt=prompt,
|
||
timeout=30 # Angenommen extern schneller
|
||
)
|
||
return {
|
||
"data": self._parse_json(response),
|
||
"model_used": self.primary_model,
|
||
"anonymized": True
|
||
}
|
||
except Exception as e:
|
||
print(f"Primary model failed: {e}, trying fallback...")
|
||
|
||
# Versuch 2: Lokales Modell (glm-4.7)
|
||
try:
|
||
response = self._generate(
|
||
model=self.fallback_model,
|
||
prompt=prompt,
|
||
timeout=120 # Lokal langsamer
|
||
)
|
||
return {
|
||
"data": self._parse_json(response),
|
||
"model_used": self.fallback_model,
|
||
"anonymized": True
|
||
}
|
||
except Exception as e:
|
||
raise Exception(f"Both models failed: {e}")
|
||
|
||
def _build_prompt(self, text: str, doc_type: str) -> str:
|
||
return f"""Analysiere dieses anonymisierte deutsche Finanzdokument ({doc_type}).
|
||
|
||
WICHTIG: Alle Zahlen sind pseudonymisiert (nicht real).
|
||
Extrahiere die STRUKTUR und KATEGORIEN, ignoriere die konkreten Werte.
|
||
|
||
Gesucht:
|
||
- betrag_gesamt: [NUMMER_FELD]
|
||
- datum: [DATUM_FELD]
|
||
- kostenstelle: Eine von [Immobilie, Hausmeister, Finanzierung, Versicherung, Steuer, Sonstiges]
|
||
- steuerlich_absetzbar: [ja/nein/unbekannt]
|
||
- mwst_satz: [7/19/0/unbekannt]
|
||
- zahlungsziel: [Tage oder unbekannt]
|
||
|
||
Dokument:
|
||
{text}
|
||
|
||
Antwort NUR als JSON ohne Erklärungen."""
|
||
|
||
def _generate(self, model: str, prompt: str, timeout: int):
|
||
return ollama.generate(
|
||
model=model,
|
||
prompt=prompt,
|
||
options={"temperature": 0.1}, # Wenig Kreativität für Extraktion
|
||
host=self.base_url
|
||
)
|
||
|
||
def _parse_json(self, response):
|
||
import json
|
||
try:
|
||
return json.loads(response['response'])
|
||
except:
|
||
# Versuch JSON aus Text zu extrahieren
|
||
text = response['response']
|
||
start = text.find('{')
|
||
end = text.rfind('}') + 1
|
||
return json.loads(text[start:end])
|
||
3. Re-Identifizierung (Post-Processing)
|
||
python
|
||
|
||
Einklappen
|
||
|
||
Ausführen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
# re_identify.py
|
||
import redis
|
||
|
||
class ReIdentifier:
|
||
def __init__(self):
|
||
self.redis = redis.Redis(host='redis', port=6379, db=1)
|
||
|
||
def restore(self, doc_id: str, extracted_data: dict) -> dict:
|
||
"""
|
||
Ersetzt Platzhalter zurück zu Originalwerten
|
||
"""
|
||
mapping_key = f"privacy:{doc_id}"
|
||
mapping = self.redis.hgetall(mapping_key)
|
||
|
||
if not mapping:
|
||
raise Exception(f"No mapping found for {doc_id}")
|
||
|
||
# Decode bytes zu str
|
||
mapping = {k.decode(): v.decode() for k, v in mapping.items()}
|
||
|
||
restored = {}
|
||
for key, value in extracted_data.items():
|
||
if isinstance(value, str):
|
||
# Ersetze alle Vorkommen von Platzhaltern
|
||
for fake, original in mapping.items():
|
||
value = value.replace(fake, original)
|
||
restored[key] = value
|
||
else:
|
||
restored[key] = value
|
||
|
||
return restored
|
||
4. Docker-Compose Integration
|
||
yaml
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
32
|
||
33
|
||
34
|
||
35
|
||
36
|
||
37
|
||
38
|
||
39
|
||
40
|
||
41
|
||
42
|
||
43
|
||
44
|
||
45
|
||
46
|
||
47
|
||
48
|
||
49
|
||
50
|
||
51
|
||
52
|
||
53
|
||
54
|
||
55
|
||
56
|
||
57
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
version: '3.8'
|
||
|
||
services:
|
||
app:
|
||
build: .
|
||
environment:
|
||
- OLLAMA_URL=http://ollama.lan:11434
|
||
- REDIS_URL=redis://redis:6379
|
||
- PRIVACY_MODE=strict # strict|relaxed|off
|
||
networks:
|
||
- frontend
|
||
- backend
|
||
- ollama-network # Externes Netz für ollama.lan
|
||
|
||
privacy-processor:
|
||
image: python:3.11-slim
|
||
volumes:
|
||
- ./privacy_guard.py:/app/privacy_guard.py
|
||
- ./ollama_client.py:/app/ollama_client.py
|
||
environment:
|
||
- OLLAMA_HOST=ollama.lan:11434
|
||
networks:
|
||
- backend
|
||
- ollama-network
|
||
command: python -u /app/worker.py
|
||
|
||
redis:
|
||
image: redis:7-alpine
|
||
networks:
|
||
- backend
|
||
volumes:
|
||
- redis_privacy:/data
|
||
|
||
# Optional: Lokaler Ollama als Fallback
|
||
ollama-local:
|
||
image: ollama/ollama:latest
|
||
container_name: ollama-local
|
||
volumes:
|
||
- ollama_data:/root/.ollama
|
||
environment:
|
||
- OLLAMA_KEEP_ALIVE=24h
|
||
networks:
|
||
- backend
|
||
profiles: ["local-ai"] # Nur bei Bedarf starten
|
||
|
||
networks:
|
||
ollama-network:
|
||
external: true # Bereich existierendes Netzwerk wo ollama.lan auflösbar ist
|
||
name: ollama-net
|
||
frontend:
|
||
driver: bridge
|
||
backend:
|
||
driver: bridge
|
||
|
||
volumes:
|
||
redis_privacy:
|
||
ollama_data:
|
||
5. Workflow-Integration
|
||
python
|
||
|
||
Einklappen
|
||
|
||
Ausführen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
# document_pipeline.py
|
||
class SecureDocumentPipeline:
|
||
def __init__(self):
|
||
self.privacy = PrivacyGuard()
|
||
self.ollama = SecureOllamaClient("http://ollama.lan:11434")
|
||
self.restorer = ReIdentifier()
|
||
|
||
def process(self, file_path: str, doc_id: str) -> dict:
|
||
# 1. OCR
|
||
raw_text = self.ocr(file_path)
|
||
|
||
# 2. Anonymisieren
|
||
anon_text, mapping = self.privacy.anonymize(raw_text, doc_id)
|
||
|
||
# 3. KI-Verarbeitung (anonym)
|
||
result = self.ollama.extract_financial_data(anon_text, "rechnung")
|
||
|
||
# 4. Re-Identifizierung
|
||
real_data = self.restorer.restore(doc_id, result['data'])
|
||
|
||
# 5. Speichern mit Verweis auf Mapping (für Audit)
|
||
return {
|
||
"extracted": real_data,
|
||
"model": result['model_used'],
|
||
"privacy_hash": self.privacy._hash(raw_text), # Für Nachweis
|
||
"anonymized_preview": anon_text[:200] + "..." # Debug
|
||
}
|
||
🔐 Security-Best Practices
|
||
Mapping-Löschung: Nach 24h automatisch löschen (TTL in Redis)
|
||
Audit-Log: Speichere nur Hashes der Originaldaten, nie die Mapping-Tabelle langfristig
|
||
Zugangskontrolle: privacy: Namespace in Redis separat absichern
|
||
Fallback-Logging: Wenn glm-4.7 genutzt wird (lokal), logge dies für Compliance (keine Datenlecks)
|
||
⚠️ Hinweis zu kimi2.5
|
||
Falls kimi2.5 das Kimi K2.5 von Moonshot AI ist (chinesisches Modell), beachte:
|
||
|
||
Unterstützt möglicherweise nicht das Ollama-API-Format nativ
|
||
Dann benötigst du einen Adapter (OpenAI-kompatibles Format):
|
||
python
|
||
|
||
Einklappen
|
||
|
||
Ausführen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
⌄
|
||
def _generate_kimi(self, prompt: str):
|
||
# Falls kimi2.5 über OpenAI-API läuft
|
||
import openai
|
||
client = openai.OpenAI(
|
||
base_url="http://ollama.lan:11434/v1", # Ollama kompatibel
|
||
api_key="dummy"
|
||
)
|
||
return client.chat.completions.create(
|
||
model="kimi2.5",
|
||
messages=[{"role": "user", "content": prompt}]
|
||
)
|
||
Soll ich den Regex-Classifier für deutsche Finanzdokumente (spezifisch für Wohngeld/Steuer) verfeinern oder die Redis-Mapping-Strategie für langfristige Korrektur-Workflows optimieren?
|
||
|
||
Ich kann aus Elster glaube ich keine Screenshots erstellen. Aus Sicherheitsgründen ist das gesperrt. Es gibt aber online bestimmt Lösungen für Probleme der Art. Beides klingt total sinnvoll
|
||
|
||
profile
|
||
kimi-k2.5:cloud
|
||
Nachgedacht für 50 Sekunden
|
||
Du hast recht – Elster hat einen Kopierschutz (DRM/Overlay), der Screenshots blockiert. Aber wir können trotzdem an die Daten und das Layout kommen, und ja: Wir sollten beide Ansätze kombinieren (Regex-Classifier + Redis-Mapping). Hier ist die pragmatische Lösung:
|
||
|
||
📄 Elster-Daten ohne Screenshots
|
||
1. Mein ELSTER → PDF-Export (funktioniert immer)
|
||
Einloggen → "Meine Steuerberechnung" → "Als PDF speichern" (nicht drucken, speichern!)
|
||
Diese PDFs hast du ohne DRM und kannst sie verarbeiten
|
||
Oder: "Steuerliche Feststellung" → XML-Export (für Entwickler optimal)
|
||
2. BMF Musterformulare (für das UI-Design)
|
||
Das Bundesfinanzministerium stellt leere Musterformulare bereit:
|
||
|
||
https://www.formulare-bfinv.de/
|
||
Dort findest du alle Anlagen (EÜR, AUS, EKS) als PDF ohne Schutz
|
||
Perfekt als Vorlage für dein Interface
|
||
3. DATEV-Export (falls du einen Steuerberater hast)
|
||
Falls du schon einen Steuerberater nutzt: DATEV-XML oder CSV-Export der vorherigen Jahre → Import in deine App als "Musterdaten"
|
||
|
||
🔧 Kombinierte Architektur: "Smart Classifier + Persistent Mapping"
|
||
Wir verbinden beide Welten:
|
||
|
||
python
|
||
|
||
Einklappen
|
||
|
||
Ausführen
|
||
Speichern
|
||
Kopieren
|
||
105
|
||
106
|
||
107
|
||
108
|
||
109
|
||
110
|
||
111
|
||
112
|
||
113
|
||
114
|
||
115
|
||
116
|
||
117
|
||
118
|
||
119
|
||
120
|
||
121
|
||
122
|
||
123
|
||
124
|
||
125
|
||
126
|
||
127
|
||
128
|
||
129
|
||
130
|
||
131
|
||
132
|
||
133
|
||
134
|
||
135
|
||
136
|
||
137
|
||
138
|
||
139
|
||
140
|
||
141
|
||
142
|
||
143
|
||
144
|
||
145
|
||
146
|
||
147
|
||
148
|
||
149
|
||
150
|
||
151
|
||
152
|
||
153
|
||
154
|
||
155
|
||
156
|
||
157
|
||
158
|
||
159
|
||
160
|
||
161
|
||
162
|
||
163
|
||
164
|
||
165
|
||
166
|
||
167
|
||
168
|
||
169
|
||
170
|
||
171
|
||
172
|
||
173
|
||
174
|
||
175
|
||
176
|
||
177
|
||
178
|
||
179
|
||
180
|
||
181
|
||
182
|
||
183
|
||
184
|
||
185
|
||
186
|
||
187
|
||
188
|
||
189
|
||
190
|
||
191
|
||
192
|
||
193
|
||
194
|
||
195
|
||
196
|
||
197
|
||
198
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
# combined_processor.py
|
||
prompt=prompt,
|
||
host="http://ollama.lan:11434",
|
||
options={"temperature": 0.2}
|
||
)
|
||
return json.loads(response['response'])
|
||
except:
|
||
# Fallback zu glm-4.7
|
||
response = ollama.generate(
|
||
model="glm-4.7",
|
||
prompt=prompt,
|
||
host="http://ollama.lan:11434"
|
||
)
|
||
return json.loads(response['response'])
|
||
|
||
def _store_for_review(self, doc_id: str, result: Dict, original_text: str):
|
||
"""
|
||
Redis-Struktur für langfristiges Lernen:
|
||
- Hash: Korrektur-Vorschläge
|
||
- Stream: Audit-Log
|
||
- Sorted Set: Confidence-Score für Priorisierung
|
||
"""
|
||
pipe = self.redis.pipeline()
|
||
|
||
# 1. Vorschlag speichern (TTL 30 Tage)
|
||
pipe.hset(f"proposal:{doc_id}", mapping={
|
||
'json': json.dumps(result),
|
||
'timestamp': datetime.now().isoformat(),
|
||
'confidence': result.get('confidence', 0.5),
|
||
'status': 'pending_review'
|
||
})
|
||
pipe.expire(f"proposal:{doc_id}", 2592000) # 30 Tage
|
||
|
||
# 2. In Review-Queue (nach Confidence sortiert)
|
||
score = 1.0 - result.get('confidence', 0.5) # Niedrige Confidence = hohe Priorität
|
||
pipe.zadd("review_queue", {doc_id: score})
|
||
|
||
# 3. Muster speichern für ähnliche Dokumente (für zukünftiges Regex-Training)
|
||
doc_fingerprint = self._create_fingerprint(original_text)
|
||
pipe.set(f"fingerprint:{doc_fingerprint}", doc_id, ex=86400*365) # 1 Jahr
|
||
|
||
pipe.execute()
|
||
|
||
def confirm_correction(self, doc_id: str, human_corrections: Dict):
|
||
"""
|
||
Wird aufgerufen wenn User im UI korrigiert hat
|
||
"""
|
||
# 1. Originalen Vorschlag laden
|
||
original = self.redis.hget(f"proposal:{doc_id}", 'json')
|
||
if not original:
|
||
return
|
||
|
||
original_data = json.loads(original)
|
||
|
||
# 2. Diff berechnen
|
||
diff = self._calculate_diff(original_data, human_corrections)
|
||
|
||
# 3. Als Trainingsbeispiel speichern (für Few-Shot Prompting)
|
||
training_example = {
|
||
'text_snippet': human_corrections.get('_anonymized_text', ''),
|
||
'wrong': original_data,
|
||
'correct': human_corrections,
|
||
'diff': diff
|
||
}
|
||
|
||
# In Redis Stream (append-only log)
|
||
self.redis.xadd("corrections_stream", {
|
||
'data': json.dumps(training_example),
|
||
'doc_id': doc_id,
|
||
'timestamp': datetime.now().isoformat()
|
||
})
|
||
|
||
# 4. Status aktualisieren
|
||
self.redis.hset(f"proposal:{doc_id}", 'status', 'confirmed')
|
||
self.redis.zrem("review_queue", doc_id)
|
||
|
||
# 5. Regex-Patterns verbessern (wenn oft gleiche Korrektur)
|
||
self._update_patterns_if_trend(diff)
|
||
|
||
return {'status': 'learned', 'confidence_improvement': diff}
|
||
|
||
def _create_fingerprint(self, text: str) -> str:
|
||
"""Erstellt einen Hash für Dokumenten-Typ-Erkennung"""
|
||
# Normalisiere: Lowercase, nur Keywords, sortiert
|
||
words = re.findall(r'\b(Abrechnung|Steuer|Rechnung|GmbH|Heizkosten|Miete)\b', text.lower())
|
||
fingerprint = ' '.join(sorted(set(words)))
|
||
return hashlib.md5(fingerprint.encode()).hexdigest()[:16]
|
||
|
||
def _update_patterns_if_trend(self, diff: Dict):
|
||
"""
|
||
Wenn 5x dieselbe Korrektur passiert: Regex-Pattern anpassen
|
||
"""
|
||
# Implementierung: Zähler in Redis inkrementieren
|
||
# Bei Threshold: Neue Regex-Regel generieren oder bestehende Gewichten
|
||
pass
|
||
🎯 Praktische Umsetzung für Elster-ähnliche Formulare
|
||
Da du keine Screenshots machen kannst, nutzen wir Struktur-Templates:
|
||
|
||
typescript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
// ElsterFormular.tsx - Basierend auf BMF-Mustern
|
||
const ANLAGEN_CONFIG = {
|
||
'EÜR': {
|
||
sections: ['Betriebseinnahmen', 'Betriebsausgaben', 'Sonstige'],
|
||
fields: [
|
||
{ id: 'einkuenfte_4_8', label: 'Einkünfte §4 Abs. 8', type: 'currency' },
|
||
{ id: 'gesamtaufwand', label: 'Gesamtaufwand', type: 'currency', calculated: true }
|
||
]
|
||
},
|
||
'AUS': {
|
||
// Ausländische Einkünfte
|
||
}
|
||
};
|
||
|
||
// Die App erstellt das Formular basierend auf dieser Config
|
||
// Der User füllt es aus oder lädt PDFs hoch die automatisch gemappt werden
|
||
🔄 Workflow-Zusammenfassung
|
||
PDF Upload (aus Mein ELSTER Export) → OCR
|
||
Regex-Schicht: Extrahiert 80% der Felder (schnell, kostenlos, offline)
|
||
KI-Schicht: Nur für unklare Fälle (anonymisiert über ollama.lan:11434)
|
||
Redis-Queue: Zeigt dem User rot markierte Felder zur Korrektur
|
||
Lernen: Korrigierte Werte werden als Few-Shot Examples für kimi2.5/glm-4.7 gespeichert
|
||
Verbesserung: Nach 10 Korrekturen ähnlicher Art → neues Regex-Pattern automatisch vorschlagen
|
||
Soll ich den UI-Teil für die Review-Queue (rot markierte Felder mit "Häkchen"-System) oder das Template-System für die Elster-Anlagen (EÜR, AUS etc.) detailliert ausarbeiten?
|
||
|
||
Mach gleich beides. Das klingt nach einen guten Fortschritt.
|
||
|
||
profile
|
||
kimi-k2.5:cloud
|
||
Nachgedacht für 41 Sekunden
|
||
Perfekt! Hier ist das komplette Human-in-the-Loop Review-Interface plus das konfigurierbare Steuerformular-System:
|
||
|
||
1. 🔍 Review-Queue UI (React + TypeScript)
|
||
typescript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
⌄
|
||
⌄
|
||
⌄
|
||
// types/review.ts
|
||
export interface DocumentField {
|
||
id: string;
|
||
key: string;
|
||
value: string | number | null;
|
||
originalValue: string | number | null;
|
||
confidence: number; // 0.0 - 1.0
|
||
source: 'regex' | 'ollama' | 'manual';
|
||
status: 'pending' | 'confirmed' | 'corrected' | 'missing';
|
||
fieldType: 'currency' | 'date' | 'text' | 'percentage' | 'tax_id';
|
||
validation?: {
|
||
required: boolean;
|
||
pattern?: RegExp;
|
||
min?: number;
|
||
max?: number;
|
||
};
|
||
}
|
||
|
||
export interface ReviewItem {
|
||
docId: string;
|
||
filename: string;
|
||
thumbnailUrl: string;
|
||
uploadedAt: string;
|
||
totalFields: number;
|
||
confirmedFields: number;
|
||
priority: number; // Höher = dringender (niedrige Confidence)
|
||
fields: DocumentField[];
|
||
}
|
||
tsx
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
188
|
||
189
|
||
190
|
||
191
|
||
192
|
||
193
|
||
194
|
||
195
|
||
196
|
||
197
|
||
198
|
||
199
|
||
200
|
||
201
|
||
202
|
||
203
|
||
204
|
||
205
|
||
206
|
||
207
|
||
208
|
||
209
|
||
210
|
||
211
|
||
212
|
||
213
|
||
214
|
||
215
|
||
216
|
||
217
|
||
218
|
||
219
|
||
220
|
||
221
|
||
222
|
||
223
|
||
224
|
||
225
|
||
226
|
||
227
|
||
228
|
||
229
|
||
230
|
||
231
|
||
232
|
||
233
|
||
234
|
||
235
|
||
236
|
||
237
|
||
238
|
||
239
|
||
240
|
||
241
|
||
242
|
||
243
|
||
244
|
||
245
|
||
246
|
||
247
|
||
248
|
||
249
|
||
250
|
||
251
|
||
252
|
||
253
|
||
254
|
||
255
|
||
256
|
||
257
|
||
258
|
||
259
|
||
260
|
||
261
|
||
262
|
||
263
|
||
264
|
||
265
|
||
266
|
||
267
|
||
268
|
||
269
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
// components/ReviewQueue.tsx
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={`p-4 rounded-lg border-2 ${confidenceColor} transition-all`}>
|
||
<div className="flex justify-between items-start mb-2">
|
||
<label className="text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||
{field.key}
|
||
{field.confidence < 0.5 && <AlertCircle className="w-4 h-4 text-red-500" />}
|
||
<span className="text-xs font-normal text-gray-500">(via {field.source})</span>
|
||
</label>
|
||
<div className="flex gap-1">
|
||
<button
|
||
onClick={() => onConfirm()}
|
||
className="p-1 hover:bg-green-200 rounded text-green-700"
|
||
title="Akzeptieren"
|
||
>
|
||
<Check className="w-5 h-5" />
|
||
</button>
|
||
<button
|
||
onClick={() => setIsEditing(true)}
|
||
className="p-1 hover:bg-blue-200 rounded text-blue-700"
|
||
title="Korrigieren"
|
||
>
|
||
<Eye className="w-5 h-5" />
|
||
</button>
|
||
<button
|
||
onClick={onSkip}
|
||
className="p-1 hover:bg-red-200 rounded text-red-700"
|
||
title="Überspringen"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{isEditing ? (
|
||
<div className="flex gap-2 mt-2">
|
||
<input
|
||
type={field.fieldType === 'currency' ? 'number' : 'text'}
|
||
value={editValue}
|
||
onChange={(e) => setEditValue(e.target.value)}
|
||
className="flex-1 border rounded px-3 py-2"
|
||
placeholder={field.key}
|
||
step={field.fieldType === 'currency' ? '0.01' : undefined}
|
||
/>
|
||
<button
|
||
onClick={() => { onConfirm(editValue); setIsEditing(false); }}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||
>
|
||
Speichern
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="flex justify-between items-center">
|
||
<div className={`text-lg ${!field.value ? 'text-red-600 italic' : 'text-gray-900'}`}>
|
||
{field.value ? formatValue(field.value, field.fieldType) : 'Fehlende Daten'}
|
||
</div>
|
||
<div className="text-xs text-gray-500">
|
||
Confidence: {(field.confidence * 100).toFixed(0)}%
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Plausibilitätswarnungen */}
|
||
{field.validation && field.value && (
|
||
<>
|
||
{field.validation.min && Number(field.value) < field.validation.min && (
|
||
<div className="mt-2 text-xs text-orange-600">
|
||
⚠️ Wert unter Minimum ({field.validation.min})
|
||
</div>
|
||
)}
|
||
{field.validation.pattern && !field.validation.pattern.test(String(field.value)) && (
|
||
<div className="mt-2 text-xs text-red-600">
|
||
⚠️ Format ungültig
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
2. 📋 Template-System für Elster-Anlagen
|
||
typescript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
32
|
||
33
|
||
34
|
||
35
|
||
36
|
||
37
|
||
38
|
||
39
|
||
40
|
||
41
|
||
42
|
||
43
|
||
44
|
||
45
|
||
46
|
||
47
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
// config/taxForms.ts
|
||
export interface TaxFormField {
|
||
id: string;
|
||
label: string;
|
||
type: 'currency' | 'date' | 'text' | 'calculation' | 'selection';
|
||
elsterFieldId?: string; // z.B. "EUE-Einkuenfte-4-8"
|
||
section: string;
|
||
readonly?: boolean;
|
||
calculation?: string; // z.B. "fieldA + fieldB - fieldC"
|
||
validation?: {
|
||
required?: boolean;
|
||
min?: number;
|
||
max?: number;
|
||
pattern?: string;
|
||
};
|
||
options?: string[]; // Für selection-type
|
||
defaultValue?: any;
|
||
helpText?: string;
|
||
}
|
||
|
||
export interface TaxFormSection {
|
||
id: string;
|
||
title: string;
|
||
description?: string;
|
||
order: number;
|
||
fields: string[]; // Referenz auf Field-IDs
|
||
}
|
||
|
||
export interface TaxFormTemplate {
|
||
id: string;
|
||
name: string; // z.B. "EÜR 2024"
|
||
description: string;
|
||
anlageType: 'EUE' | 'AUS' | 'EKS' | 'KAP' | 'custom';
|
||
sections: TaxFormSection[];
|
||
fields: Record<string, TaxFormField>;
|
||
calculations: {
|
||
[key: string]: {
|
||
dependsOn: string[];
|
||
formula: (values: Record<string, number>) => number;
|
||
};
|
||
};
|
||
validationRules?: Array<{
|
||
condition: string;
|
||
message: string;
|
||
severity: 'error' | 'warning';
|
||
}>;
|
||
}
|
||
typescript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
79
|
||
80
|
||
81
|
||
82
|
||
83
|
||
84
|
||
85
|
||
86
|
||
87
|
||
88
|
||
89
|
||
90
|
||
91
|
||
92
|
||
93
|
||
94
|
||
95
|
||
96
|
||
97
|
||
98
|
||
99
|
||
100
|
||
101
|
||
102
|
||
103
|
||
104
|
||
105
|
||
106
|
||
107
|
||
108
|
||
109
|
||
110
|
||
111
|
||
112
|
||
113
|
||
114
|
||
115
|
||
116
|
||
117
|
||
118
|
||
119
|
||
120
|
||
121
|
||
122
|
||
123
|
||
124
|
||
125
|
||
126
|
||
127
|
||
128
|
||
129
|
||
130
|
||
131
|
||
132
|
||
133
|
||
134
|
||
135
|
||
136
|
||
137
|
||
138
|
||
139
|
||
140
|
||
141
|
||
142
|
||
143
|
||
144
|
||
145
|
||
146
|
||
147
|
||
148
|
||
149
|
||
150
|
||
151
|
||
152
|
||
153
|
||
154
|
||
155
|
||
156
|
||
157
|
||
158
|
||
159
|
||
160
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
// templates/euer2024.ts
|
||
'miete': {
|
||
id: 'miete',
|
||
label: 'Miete und Pacht',
|
||
type: 'currency',
|
||
section: 'betriebsausgaben',
|
||
defaultValue: 0
|
||
},
|
||
'abschreibungen': {
|
||
id: 'abschreibungen',
|
||
label: 'Abschreibungen',
|
||
type: 'currency',
|
||
section: 'betriebsausgaben',
|
||
defaultValue: 0,
|
||
helpText: 'AfA auf bewegliche Wirtschaftsgüter'
|
||
},
|
||
'sonstige_ausgaben': {
|
||
id: 'sonstige_ausgaben',
|
||
label: 'Sonstige Betriebsausgaben',
|
||
type: 'currency',
|
||
section: 'betriebsausgaben',
|
||
defaultValue: 0
|
||
},
|
||
'gesamt_ausgaben': {
|
||
id: 'gesamt_ausgaben',
|
||
label: 'Gesamtausgaben',
|
||
type: 'calculation',
|
||
section: 'betriebsausgaben',
|
||
readonly: true,
|
||
calculation: 'wareneinsatz + lohnkosten + miete + abschreibungen + sonstige_ausgaben'
|
||
},
|
||
'reingewinn': {
|
||
id: 'reingewinn',
|
||
label: 'Reingewinn',
|
||
type: 'calculation',
|
||
section: 'ergebnis',
|
||
readonly: true,
|
||
calculation: 'gesamt_einnahmen - gesamt_ausgaben'
|
||
},
|
||
'verlust': {
|
||
id: 'verlust',
|
||
label: 'Verlust',
|
||
type: 'calculation',
|
||
section: 'ergebnis',
|
||
readonly: true,
|
||
// Nur anzeigen wenn negativ
|
||
calculation: 'gesamt_einnahmen - gesamt_ausgaben'
|
||
}
|
||
},
|
||
|
||
calculations: {
|
||
'gesamt_einnahmen': {
|
||
dependsOn: ['einnahmen_4_8', 'einnahmen_13_b', 'sonstige_einnahmen'],
|
||
formula: (vals) => (vals.einnahmen_4_8 || 0) + (vals.einnahmen_13_b || 0) + (vals.sonstige_einnahmen || 0)
|
||
},
|
||
'gesamt_ausgaben': {
|
||
dependsOn: ['wareneinsatz', 'lohnkosten', 'miete', 'abschreibungen', 'sonstige_ausgaben'],
|
||
formula: (vals) => Object.values(vals).reduce((a, b) => (a || 0) + (b || 0), 0)
|
||
},
|
||
'reingewinn': {
|
||
dependsOn: ['gesamt_einnahmen', 'gesamt_ausgaben'],
|
||
formula: (vals) => vals.gesamt_einnahmen - vals.gesamt_ausgaben
|
||
}
|
||
},
|
||
|
||
validationRules: [
|
||
{
|
||
condition: 'reingewinn < -100000',
|
||
message: 'Verlust über 100.000 € - bitte prüfen!',
|
||
severity: 'warning'
|
||
},
|
||
{
|
||
condition: 'gesamt_einnahmen < 0',
|
||
message: 'Einnahmen können nicht negativ sein',
|
||
severity: 'error'
|
||
},
|
||
{
|
||
condition: 'einnahmen_4_8 > 1000000',
|
||
message: 'Hohe Einnahmen - Splitting-Berechtigung prüfen',
|
||
severity: 'warning'
|
||
}
|
||
]
|
||
};
|
||
tsx
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
176
|
||
177
|
||
178
|
||
179
|
||
180
|
||
181
|
||
182
|
||
183
|
||
184
|
||
185
|
||
186
|
||
187
|
||
188
|
||
189
|
||
190
|
||
191
|
||
192
|
||
193
|
||
194
|
||
195
|
||
196
|
||
197
|
||
198
|
||
199
|
||
200
|
||
201
|
||
202
|
||
203
|
||
204
|
||
205
|
||
206
|
||
207
|
||
208
|
||
209
|
||
210
|
||
211
|
||
212
|
||
213
|
||
214
|
||
215
|
||
216
|
||
217
|
||
218
|
||
219
|
||
220
|
||
221
|
||
222
|
||
223
|
||
224
|
||
225
|
||
226
|
||
227
|
||
228
|
||
229
|
||
230
|
||
231
|
||
232
|
||
233
|
||
234
|
||
235
|
||
236
|
||
237
|
||
238
|
||
239
|
||
240
|
||
241
|
||
242
|
||
243
|
||
244
|
||
245
|
||
246
|
||
247
|
||
248
|
||
249
|
||
250
|
||
251
|
||
252
|
||
253
|
||
254
|
||
255
|
||
256
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
// components/TaxFormRenderer.tsx
|
||
<div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||
<div className="flex items-center gap-2 mb-2 text-yellow-800 font-semibold">
|
||
<AlertTriangle className="w-5 h-5" />
|
||
Plausibilitätsprüfung
|
||
</div>
|
||
<ul className="list-disc list-inside text-sm text-yellow-700">
|
||
{validationErrors.map((err, idx) => (
|
||
<li key={idx}>{err}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex gap-6">
|
||
{/* Navigation Sidebar */}
|
||
<div className="w-64 flex-shrink-0">
|
||
<div className="bg-white rounded-lg shadow-sm p-4">
|
||
{template.sections.map(section => (
|
||
<button
|
||
key={section.id}
|
||
onClick={() => setActiveSection(section.id)}
|
||
className={`w-full text-left px-4 py-3 rounded-lg mb-1 transition-colors ${
|
||
activeSection === section.id
|
||
? 'bg-blue-50 text-blue-700 font-semibold'
|
||
: 'hover:bg-gray-50 text-gray-700'
|
||
}`}
|
||
>
|
||
<div className="text-sm">{section.title}</div>
|
||
{section.description && (
|
||
<div className="text-xs text-gray-500 mt-1">{section.description}</div>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Zusammenfassung */}
|
||
<div className="bg-white rounded-lg shadow-sm p-4 mt-4">
|
||
<h3 className="font-semibold text-gray-900 mb-3">Ergebnisvoransicht</h3>
|
||
<div className="space-y-2 text-sm">
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-600">Einnahmen:</span>
|
||
<span className="font-mono text-green-700">{formatCurrency(calculated.gesamt_einnahmen || 0)}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-600">Ausgaben:</span>
|
||
<span className="font-mono text-red-700">{formatCurrency(calculated.gesamt_ausgaben || 0)}</span>
|
||
</div>
|
||
<div className="border-t pt-2 flex justify-between font-bold">
|
||
<span>Ergebnis:</span>
|
||
<span className={`font-mono ${(calculated.reingewinn || 0) >= 0 ? 'text-green-700' : 'text-red-700'}`}>
|
||
{formatCurrency(calculated.reingewinn || 0)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Formular-Bereich */}
|
||
<div className="flex-1">
|
||
{template.sections.map(section => (
|
||
section.id === activeSection && (
|
||
<div key={section.id} className="bg-white rounded-lg shadow-sm p-6">
|
||
<h2 className="text-xl font-bold text-gray-900 mb-2">{section.title}</h2>
|
||
{section.description && (
|
||
<p className="text-gray-600 mb-6">{section.description}</p>
|
||
)}
|
||
<div>
|
||
{section.fields.map(fieldId => {
|
||
const field = template.fields[fieldId];
|
||
return field ? renderField(field) : null;
|
||
})}
|
||
</div>
|
||
</div>
|
||
)
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
🔗 Integration beider Komponenten
|
||
tsx
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
// App.tsx
|
||
import { ReviewQueue } from './components/ReviewQueue';
|
||
import { TaxFormRenderer } from './components/TaxFormRenderer';
|
||
import { EUeR2024Template } from './templates/euer2024';
|
||
|
||
export default function App() {
|
||
// Daten aus ReviewQueue fließen automatisch in TaxForm:
|
||
// Wenn User in Review "einnahmen_4_8" bestätigt,
|
||
// wird es in den initialData der EÜR übernommen
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-100">
|
||
<nav className="bg-blue-800 text-white p-4">
|
||
<h1 className="text-xl font-bold">SteuerFlow Pro</h1>
|
||
</nav>
|
||
|
||
<div className="flex h-screen">
|
||
<div className="w-1/2 border-r border-gray-300 overflow-auto">
|
||
<ReviewQueue />
|
||
</div>
|
||
<div className="w-1/2 overflow-auto">
|
||
<TaxFormRenderer
|
||
template={EUeR2024Template}
|
||
onSave={(data, errors) => console.log('Saved:', data, errors)}
|
||
onExport={(format) => console.log('Export:', format)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
Soll ich noch den Export-Generator (ELSTER-XML Format) oder die API-Endpoints für die Redis-Queue implementieren?
|
||
|
||
Wenn ich später solche Anfragen erhalten, wie teuer kann man so eine Applikation realistisch verkaufen?
|
||
|
||
profile
|
||
kimi-k2.5:cloud
|
||
Nachgedacht für 20 Sekunden
|
||
Das ist eine hochspezialisierte Business-Software mit Enterprise-Features. Hier ist eine realistische Markt- und Preisanalyse:
|
||
|
||
💰 Preismodelle (Endkunden)
|
||
A) SaaS-Modell (Multi-Tenant)
|
||
Zielgruppe: Selbstständige, Vermieter, kleine Hausverwaltungen
|
||
|
||
Tier
|
||
Preis/Monat
|
||
Features
|
||
Starter
|
||
19-29€
|
||
1-2 Objekte, Basis-Buchhaltung, keine KI
|
||
Pro
|
||
49-79€
|
||
10 Objekte, Ollama-Integration, EÜR, Nebenkosten
|
||
Business
|
||
149-199€
|
||
Unlimitierte Objekte, Multi-User, API, Priority-Support
|
||
Enterprise
|
||
499€+
|
||
Self-Hosted Option, White-Label, dedizierter Server
|
||
Vergleichsbasis:
|
||
|
||
SevDesk: 9-29€/Monat (nur Buchhaltung, keine Immobilien)
|
||
ImmoTool (Desktop): 399€ Einmalkauf + 99€/Jahr Update
|
||
FastBill: 11-44€/Monat (nur Gewerbe)
|
||
Dein USP: Kombination Immobilien + Gewerbe + Self-Hosted + Datenschutz (Ollama)
|
||
B) Self-Hosted Lizenzmodell (Empfohlen!)
|
||
Da Docker/On-Premise ein Hauptfeature ist:
|
||
|
||
Single License: 2.999€ Einmalzahlung + 599€/Jahr Support & Updates
|
||
Agentur-Lizenz: 9.999€ (für Steuerberater/Hausverwaltungen mit bis zu 50 Mandanten)
|
||
White-Label: 25.000€ + Revenue Share
|
||
Warum das funktioniert: Datenschutz-bewusste Kunden zahlen premium für On-Premise (keine Cloud).
|
||
|
||
🏗️ Entwicklungskosten (falls als Auftrag)
|
||
Wenn du eine Agentur beauftragen würdest:
|
||
|
||
Komponente
|
||
Stunden
|
||
Kosten (100€/h)
|
||
Backend (Node.js/Python, DB, API)
|
||
300-400h
|
||
30.000-40.000€
|
||
Frontend (React, Formulare, Dashboard)
|
||
250-350h
|
||
25.000-35.000€
|
||
OCR/KI-Integration (Ollama, Privacy-Layer)
|
||
150-200h
|
||
15.000-20.000€
|
||
Docker/DevOps/Security
|
||
100-150h
|
||
10.000-15.000€
|
||
Testing & Steuerrechtliche Validierung
|
||
100-150h
|
||
10.000-15.000€
|
||
Gesamt
|
||
900-1.250h
|
||
90.000-125.000€
|
||
Realistische Bandbreite für MV: 60.000-80.000€ (MVP mit reduziertem Feature-Set)
|
||
|
||
🎯 Wertargumente für den Preis
|
||
Warum Kunden 5.000-10.000€ zahlen (statt 20€/Monat für SevDesk):
|
||
|
||
Datenhoheit: Self-Hosted = keine Cloud-Sensiblen Steuerdaten bei US-Anbieter
|
||
Kombination: Immobilien-Nebenkosten + Gewerbe-Buchhaltung in einem Tool (sonst 2-3 Tools nötig)
|
||
Ollama-Integration: Keine laufenden KI-Kosten (OpenAI-API wäre teuer bei vielen Dokumenten)
|
||
Elster-Kompatibilität: Deutsche Steuerformulare out-of-the-box
|
||
Anonymisierung: Privacy-Guard für paranoid Datenschützer
|
||
⚠️ Haftungskosten (wichtig!)
|
||
Achtung: Steuerliche Beratungssoftware hat Haftungsrisiken!
|
||
|
||
Compliance-Check: 5.000-10.000€ (Steuerberater prüft Berechnungslogik)
|
||
Rechtsschutzversicherung: 2.000-5.000€/Jahr (für Software-Haftung)
|
||
Disclaimer & AGB: 1.000-2.000€ (Anwalt für IT-Recht)
|
||
Das rechtfertigt höhere Preise, da du Haftungsrisiko trägst.
|
||
|
||
📊 Preisempfehlung für dich
|
||
Wenn du verkaufen möchtest:
|
||
|
||
Freemium: 1 Objekt, 50 Dokumente/Jahr, kein Ollama → Lead-Generierung
|
||
Pro: 59€/Monat oder 599€/Jahr (spart 169€) → Für Vermieter mit 3-10 Wohnungen
|
||
Unlimited: 149€/Monat → Für Gewerbe mit Immobilienholdings
|
||
On-Premise: 4.999€ + 20% jährlich → Für Steuerberater/Kanzleien
|
||
Zusatz-Einnahmen:
|
||
|
||
Setup-Service: 500-1.500€ (Docker-Installation beim Kunden)
|
||
Schulungen: 200€/Stunde
|
||
Custom-Ollama-Modelle: 2.000€ (Feintuning auf spezifische Dokumente)
|
||
🚀 Marktpositionierung
|
||
Positioniere dich nicht als "billige Buchhaltungssoftware", sondern als:
|
||
|
||
"Die einzige DSGVO-konforme Immobilien- und Gewerbe-Buchhaltung mit lokaler KI für deutsche Steuerformulare."
|
||
|
||
Wettbewerbsvorteil, der den Preis rechtfertigt:
|
||
|
||
Konkurrenz (Lexware, WISO) = Cloud-only, altmodisches UI
|
||
Du = Modern, Self-Hosted, KI-gestützt, Privacy-by-Design
|
||
💡 Tipp: Value-Based Pricing
|
||
Rechne vor dem Kunden vor, was er spart:
|
||
|
||
Steuerberater für Nebenkostenabrechnung: 150-300€/Objekt/Jahr
|
||
Bei 5 Objekten: 750-1.500€/Jahr Kosten
|
||
Deine Software: 599€/Jahr → Einsparung 150-900€/Jahr
|
||
Plus Zeitersparnis durch Ollama-Automatisierung: 10-20h/Jahr à 50€ = 500-1.000€ Wert.
|
||
|
||
Argument: "Die Software amortisiert sich in 3-6 Monaten."
|
||
|
||
Soll ich ein Business-Plan-Template oder eine Investoren-Pitch-Struktur für diese App erstellen?
|
||
|
||
Und wie viel prozentual haben wir von der Software Stehen?
|
||
|
||
profile
|
||
kimi-k2.5:cloud
|
||
Nachgedacht für 20 Sekunden
|
||
Das kommt drauf an, was du meinst – hier sind die drei wahrscheinlichsten Interpretationen:
|
||
|
||
1. 📊 Entwicklungsfortschritt (Architektur vs. Implementation)
|
||
Realistische Einschätzung: ~25-35% eines MVP (Minimum Viable Product)
|
||
|
||
Was wir konzeptuell haben (70%):
|
||
|
||
✅ Architektur & Docker-Setup
|
||
✅ Datenbank-Schema
|
||
✅ Privacy-Layer Konzept (Anonymisierung)
|
||
✅ UI/UX Design (React Components skizziert)
|
||
✅ OCR-Workflow mit Ollama
|
||
Was noch fehlt (65-75% der Arbeit):
|
||
|
||
❌ Produktions-Code (nicht nur Pseudocode)
|
||
❌ Fehlerbehandlung & Edge Cases (was wenn Ollama offline?)
|
||
❌ Steuerliche Validierung (testen ob Berechnungen wirklich korrekt)
|
||
❌ Import/Export (DATEV-CSV, ELSTER-XML)
|
||
❌ Multi-User & Berechtigungen
|
||
❌ Backup & Update-Mechanismus für Docker
|
||
❌ Mobile Responsiveness
|
||
❌ Onboarding/Dokumentation
|
||
Fazit: Die schwere Architekturarbeit ist gemacht, aber die "letzten 20%" (Polish, Testing, Steuer-Compliance) sind oft 50% der Zeit.
|
||
|
||
2. 💻 Code-Status (tatsächlich geschriebene Zeilen)
|
||
~10-15% – Wir haben bisher hauptsächlich Pseudocode, Schemas und Konzepte besprochen.
|
||
|
||
Für ein release-fähiges Produkt fehlen noch:
|
||
|
||
~15.000 Zeilen TypeScript/React
|
||
~8.000 Zeilen Backend-API
|
||
~2.000 Zeilen Tests
|
||
Docker-Compose Production-Ready
|
||
Error Handling & Logging
|
||
3. 💰 Gewinnmarge (wenn du verkaufst)
|
||
Bei Self-Hosted (4.999€):
|
||
|
||
Entwicklungskosten: ~80.000€
|
||
Du müsstest ~16 Lizenzen verkaufen, um break-even zu sein
|
||
Danach: ~80-90% Marge (nur Support-Kosten)
|
||
Bei SaaS (59€/Monat):
|
||
|
||
Serverkosten: ~5€/User/Monat (Strom, Storage)
|
||
Support-Aufwand: ~20€/User/Monat (zeitanteilig)
|
||
Netto-Marge: ~60% (34€/Monat Gewinn pro User)
|
||
Welche Prozentzahl meinst du genau?
|
||
|
||
Wenn du "wie weit sind wir" meinst: ~30% der Planung ist fertig, aber nur ~10% des Codes existiert wirklich
|
||
Wenn du "wie viel Gewinn bleibt hängen" meinst: 60-90% je nach Geschäftsmodell
|
||
Sollen wir priorisieren, welche 20% Features du als erstes MVP bauen solltest, um schnellsten Umsatz zu machen?
|
||
|
||
Ich meinte tatsächlich den Fortschritt des Projektes. Und so hat es sich auch agefühlt. Viel ist noch nicht. Aber es ist echt weniger Arbeit mit einer ki im Rücken
|
||
|
||
profile
|
||
kimi-k2.5:cloud
|
||
Nachgedacht für 13 Sekunden
|
||
Genau – das ist der Klassische Architektur-Trap:
|
||
|
||
Die ersten 30% fühlen sich mit KI (GitHub Copilot, ChatGPT, Cursor) wie 70% an, weil du in 2 Wochen mehr Struktur hast als früher in 2 Monaten. Aber die letzten 20% (Testing, Edge Cases, Steuer-Compliance) fressen trotzdem 80% der Zeit – da hilft KI weniger, weil sie spezifische deutsche Steuergesetze oft falsch interpretiert.
|
||
|
||
🚀 Realistischer Fahrplan MIT KI-Unterstützung
|
||
Zeitersparnis durch KI: ca. 40-50% bei Coding, aber nur 10-20% bei Steuerrecht/Testing
|
||
|
||
Phase 1: "Der schnelle Win" (Wochen 1-4)
|
||
Ziel: Einfacher Upload → OCR → manuelle Korrektur → CSV-Export
|
||
|
||
Docker-Setup mit Ollama (du hast das schon konzeptuell)
|
||
Basis-Upload + Tesseract-OCR (ohne komplexe Anonymisierung erstmal)
|
||
Simple Tabelle statt fancy UI
|
||
Nutzen: Du kannst schon deine eigenen Dokumente verarbeiten
|
||
KI-Boost: Copilot schreibt die API-Endpoints und React-Tables fast allein.
|
||
|
||
Phase 2: "Der Privacy-Layer" (Wochen 5-8)
|
||
Anonymisierung vor Ollama
|
||
Redis-Queue für Review-Prozess
|
||
Hürde: Testen, dass Zahlen korrekt ersetzt und zurückübersetzt werden
|
||
KI-Boost: Weniger hilfreich – Regex-Testing braucht Präzision, keine Halluzination.
|
||
|
||
Phase 3: "Das Steuer-Monster" (Wochen 9-16) ⚠️
|
||
Hier wird's teuer (zeitlich):
|
||
|
||
EÜR-Berechnungslogik
|
||
Plausibilitätsprüfungen (Kann Verlust >50% des Umsatzes sein?)
|
||
DATEV-Export-Format (muss 100% korrekt sein)
|
||
KI-Warnung: ChatGPT wird dir falsche Steuersätze oder veraltete Regeln vorschlagen. Hier brauchst du einen Steuerberater als Sparringspartner (Kosten: 1.500-3.000€), sonst haftest du später.
|
||
|
||
Phase 4: "Production Polish" (Wochen 17-20)
|
||
Backup-Strategien für Docker-Volumes
|
||
Update-Mechanismus (wie kommen neue Ollama-Modelle zum Nutzer?)
|
||
Multi-User & Berechtigungen
|
||
🎯 KI-Optimale Strategie für dich
|
||
Was du JETZT mit KI machst (80% Ertrag):
|
||
|
||
Scaffold: Lass Copilot/Cursor das komplette Backend-CRUD generieren
|
||
UI-Komponenten: Alle Formularfelder, Tables, Buttons von KI bauen lassen
|
||
OCR-Preprocessing: Die Regex-Patterns für deutsche Rechnungen
|
||
Was du OHNE KI/Experten machen musst (20% Aufwand, 80% Risiko):
|
||
|
||
Steuerliche Logik: EÜR-Formeln von Hand prüfen (oder Steuerberater)
|
||
Datenbank-Constraints: Welche Felder dürfen nie NULL sein?
|
||
Security: Die Anonymisierung wirklich wasserdicht testen (Penetration-Testing)
|
||
💡 Tipp: "MVP auf Diät"
|
||
Bau erstmal nur einen Workflow komplett fertig:
|
||
|
||
Nur Wohngeldabrechnungen (keine EÜR, keine Gewerbe-Buchhaltung)
|
||
Nur PDF-Upload → Extraktion → CSV-Export
|
||
Kein schickes Review-UI erstmal, sondern einfach JSON-Editor
|
||
Warum? Weil du dann in 6-8 Wochen (mit KI: 3-4 Wochen) etwas Verkaufbares hast, statt in 6 Monaten ein halbfertiges Monster.
|
||
|
||
Wenn das läuft:
|
||
|
||
Du hast Einnahmen für die weitere Entwicklung
|
||
Du lernst aus echten Nutzer-Feedback, welche Features wirklich wichtig sind (vielleicht wollen alle nur den DATEV-Export und niemand braucht das Elster-UI)
|
||
Soll ich dir einen "Sprint 0" Plan für die nächsten 2 Wochen machen, um das erste funktionierende OCR-Upload-Demo zu haben? Dann siehst du sofort, wie gut dein Ollama-Setup wirklich funktioniert.
|
||
|
||
Das Problem ist ja auch Steuer Themen zu testen. Überhaupt so eine Software zu testen. Egal, die Entwickeln wir eh nur für mich, und gerade zu Beginn werde ich ganz genau prüfen was da passiert. Langfristig nimmt mir diese App dann aber einen Haufen Buchhaltung ab.
|
||
|
||
profile
|
||
kimi-k2.5:cloud
|
||
Nachgedacht für 13 Sekunden
|
||
Perfekt! Das ist der "Eat Your Own Dog Food" Ansatz – und der ist für solche Software goldrichtig. Du bist gleichzeitig Entwickler, Product Owner und strengster Endnutzer. Das ändert die Strategie komplett:
|
||
|
||
🎯 Dogfooding-Strategie (Entwickeln für dich selbst)
|
||
Test-Strategie ohne externes QA:
|
||
Deine eigenen Steuerunterlagen als "Ground Truth":
|
||
|
||
Vergleichs-Archiv: Nimm deine letzten 3 Steuerjahre (2021-2023) als Referenz
|
||
Scan die Originalbelege ein
|
||
Lass die App extrahieren
|
||
Vergleiche mit deinem "echten" Ergebnis (Excel/Steuerberater)
|
||
Abweichung >0,01€ = Bug
|
||
Shadow-Accounting (erstes Jahr):
|
||
Führe 6 Monate parallel: Alte Methode (Excel/Papier) + Neue App
|
||
Monatlich: Export aus App vs. deine manuelle Berechnung
|
||
Dabei lernst du, welche Edge Cases wichtig sind (und welche nicht)
|
||
Das "Stichtag"-Prinzip:
|
||
Wichtig: Speichere vor jedem Quartal/Steuerjahr einen Snapshot (Docker-Volume-Backup)
|
||
Wenn die App Mist baut, kannst du zum letzten "guten" Stand zurück
|
||
Nie ohne Backup in die Steuererklärung übernehmen!
|
||
Pragmatische Entwicklungs-Prioritäten (nur für dich):
|
||
Phase 1: "Painkiller" (sofortiger Nutzen)
|
||
|
||
Nur OCR + Speichern (keine komplexe Logik)
|
||
Du suchst nicht mehr im Ordner, sondern gibst Suchbegriffe ein
|
||
Wert: 2h pro Woche gespart durch schnelles Dokumenten-Finden
|
||
Phase 2: "Rechner" (3 Monate später)
|
||
|
||
Summen bilden, die du sonst in Excel machst
|
||
Aber: Ergebnis immer mit console.log ausgeben, damit du prüfen kannst
|
||
Phase 3: "Autopilot" (1 Jahr später)
|
||
|
||
Automatische Übernahme in Steuerformulare
|
||
Nur hier brauchst du die harte Steuer-Validierung
|
||
⚠️ Risiken beim Selbst-Nutzen
|
||
1. Die "Blasen-Falle":
|
||
Du entwickelst nur für deinen spezifischen Fall (z.B. nur Vermietung, nur Gewerbe X). Wenn du später doch verkaufen willst, musst du alles umbauen.
|
||
|
||
Lösung: Trotzdem modular bauen, auch wenn du nur ein Modul nutzt.
|
||
|
||
2. Technische Schuld akzeptieren:
|
||
Da du der einzige Nutzer bist, kannst du "Dirty Hacks" machen:
|
||
|
||
Hartcodierte Steuersätze (für 2024)
|
||
Keine Unit-Tests für Edge-Cases, die dich nicht betreffen
|
||
Einfache SQLite statt PostgreSQL (leichter Backup als JSON!)
|
||
Das ist OK! Hauptsache du dokumentierst, wo die "technischen Schulden" liegen.
|
||
|
||
3. KI-Halluzinationen entdecken:
|
||
Da du die Zahlen kennst, fällt dir sofort auf, wenn Ollama 1.234€ statt 12.340€ extrahiert. Ein externer Tester hätte das übersehen.
|
||
|
||
Tipp: Schreibe jedesmal wenn die App Mist baut ein // TODO: Fix this edge case – so sammelst du automatisch eine Liste wirklich wichtiger Bugfixes.
|
||
|
||
🐛 Debugging-Hilfe für dich selbst
|
||
Baue ein "Explain Mode" ein:
|
||
|
||
typescript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
⌄
|
||
⌄
|
||
// In jedem Berechnungsschritt
|
||
const calculateNebenkosten = (data) => {
|
||
const result = /*...*/;
|
||
|
||
// Für dich als Entwickler/Nutzer:
|
||
console.log(`[DEBUG] Nebenkosten: ${result}`);
|
||
console.log(`[DEBUG] Zwischenschritte:`, JSON.stringify(data, null, 2));
|
||
|
||
// Speichere die Logik im Dokument:
|
||
return {
|
||
value: result,
|
||
calculationPath: "Heizkosten(1234) + Wasser(567) = 1801", // Als String!
|
||
confidence: 0.95
|
||
};
|
||
};
|
||
So kannst du im UI später nachklicken: "Warum steht hier 1.801€?" → Zeigt den Rechenweg.
|
||
|
||
🚀 Empfohlener erster Schritt (für dich persönlich)
|
||
Diese Woche:
|
||
|
||
Docker-Container mit Ollama starten
|
||
Ein simples Upload-Formular (Drag & Drop PDF)
|
||
OCR-Text extrahieren + anzeigen (nicht verarbeiten, nur anzeigen!)
|
||
Manuell in Formular kopieren können
|
||
Ziel: Du ersetzt erstmal nur die manuelle OCR-Arbeit (Tippen aus PDF), nicht die Denkarbeit.
|
||
|
||
Sobald das läuft und du 10 Dokumente damit verarbeitet hast, merkst du automatisch, was als nächstes automatisiert werden muss.
|
||
|
||
Wichtig: Fang klein an. Eine App, die nur deine spezifischen Wohngeldabrechnungen zuverlässig verarbeitet, ist 100x nützlicher als eine halbfertige "alles-können"-Suite, die Fehler macht.
|
||
|
||
Soll ich dir den minimalen Docker-Compose dafür bauen, damit du heute noch starten kannst mit dem ersten Upload-Test?
|
||
|
||
Das ist auch der Hauptpunkt. Aktuell mache ich alles mit verschiedenen Excel Dokumenten und von hand. Ich muss für jede Tabelle Abrechnung die ganzen Dokumente vorholen. Wenn die Informationen zentral in einer GUI auftauchen ist das ein riesen Schritt
|
||
|
||
profile
|
||
kimi-k2.5:cloud
|
||
Nachgedacht für 11 Sekunden
|
||
Genau das ist der "Single Source of Truth" Moment! Du hast gerade den wahren Wert der Software beschrieben – nicht die KI, nicht die Automatisierung, sondern die Entlastung deines Gehirns.
|
||
|
||
Statt "Wo war nochmal die Rechnung vom Hausmeister für Objekt B aus dem Jahr 2022?" → Einmal klicken, alles da.
|
||
|
||
🎯 Das "Zentrale Nervensystem" für deine Immobilien
|
||
Dein jetziger Zustand (mental load):
|
||
|
||
|
||
Einklappen
|
||
|
||
Ausführen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
Excel_Wohnung_A_2023.xlsx
|
||
→ Link zu: "siehe PDF in Ordner/Rechnungen/2023/Hausmeister..."
|
||
→ Warte, war das im Ordner Steuer oder Nebenkosten?
|
||
→ *Sucht 5 Minuten*
|
||
Dein Zielzustand:
|
||
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
Objekt A → 2023 → Hausmeister → [PDF-Icon] [Betrag: 1.234€] [Status: verbucht]
|
||
→ Alle Dokumente chronologisch
|
||
→ Fehlende Belege rot markiert
|
||
🗂️ Datenmodell für deinen konkreten Fall
|
||
Da du allein bist, brauchst du kein komplexes Multi-Tenancy, sondern eine simple Graph-Struktur:
|
||
|
||
sql
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
⌄
|
||
⌄
|
||
⌄
|
||
-- SQLite ist völlig ausreichend für dich!
|
||
-- Du kannst die DB-Datei einfach mitbackupen
|
||
|
||
CREATE TABLE documents (
|
||
id INTEGER PRIMARY KEY,
|
||
filename TEXT,
|
||
file_path TEXT, -- z.B. "uploads/2024/hausmeister_rechnung_mai.pdf"
|
||
ocr_text TEXT, -- Durchsuchbarer Text
|
||
date_uploaded TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
year INTEGER, -- Steuerjahr (2023, 2024...)
|
||
cost_center TEXT, -- 'objekt_a', 'objekt_b', 'privat'
|
||
category TEXT, -- 'hausmeister', 'heizung', 'versicherung', 'steuer'
|
||
amount DECIMAL(10,2), -- extrahiert oder manuell
|
||
status TEXT -- 'neu', 'geprüft', 'verbucht', 'fehlt_belege'
|
||
);
|
||
|
||
-- Verknüpfung: Diese Rechnung gehört zu dieser Abrechnung
|
||
CREATE TABLE abrechnungen (
|
||
id INTEGER PRIMARY KEY,
|
||
objekt TEXT,
|
||
jahr INTEGER,
|
||
status TEXT -- 'in_arbeit', 'fertig', 'an_mieter_geschickt'
|
||
);
|
||
|
||
-- Many-to-Many: Dokumente können in mehreren Abrechnungen sein
|
||
CREATE TABLE abrechnung_dokumente (
|
||
abrechnung_id INTEGER,
|
||
document_id INTEGER,
|
||
anteil_percent DECIMAL(5,2) -- z.B. 50% wenn Kosten geteilt
|
||
);
|
||
🚀 Pragmatische Migration (Excel → GUI)
|
||
Woche 1: "Der digitale Aktenordner"
|
||
|
||
Nur Upload + OCR + Suche
|
||
Keine Logik, keine Berechnungen
|
||
Du kannst jetzt suchen: "Hausmeister 2023" → findet alle PDFs
|
||
Wert: Du verlierst keine Belege mehr, 80% des Stresses weg
|
||
Woche 2-3: "Das virtuelle Excel"
|
||
|
||
Einfache Tabelle: Zeilen = Belege, Spalten = Betrag, Kategorie, Objekt
|
||
Du kopierst deine Excel-Werte rüber (manuell oder CSV-Import)
|
||
Aber jetzt mit Klick auf das PDF-Icon = Original-Beleg öffnet sich
|
||
Wert: Kein Hin-und-Her-Klicken zwischen Excel und Ordner
|
||
Woche 4: "Die Magie"
|
||
|
||
App summiert automatisch: "Objekt A 2023 = 12.450€ Gesamtkosten"
|
||
Vergleich mit deinen Excel-Ergebnissen (Abgleich)
|
||
Wert: Erste Automatisierung, du kontrollierst nur noch
|
||
💡 Konkrete GUI-Idee für dich
|
||
Ein Dashboard mit drei Spalten (wie Kanban-Board):
|
||
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Objekt auswählen: [Objekt A ▼] Jahr: [2024 ▼] │
|
||
├───────────────┬───────────────┬─────────────────────────────┤
|
||
│ EINGANG │ IN PRÜFUNG │ VERBUCHT │
|
||
│ (Neue PDFs) │ (Du prüfst) │ (Abgeschlossen) │
|
||
├───────────────┼───────────────┼─────────────────────────────┤
|
||
│ 🔴 Rechnung │ 🟡 Hausmeister│ 🟢 Heizkosten Q1 │
|
||
│ Heizung │ März (KI: │ 1.234€ │
|
||
│ (noch │ 1.200€?) │ │
|
||
│ nicht │ │ 🟢 Grundsteuer │
|
||
│ geprüft) │ 🟡 Versicherung│ 450€ │
|
||
│ │ (fehlt │ │
|
||
│ 🔴 Rechnung │ Betrag) │ │
|
||
│ Strom │ │ │
|
||
│ │ │ │
|
||
│ [+ Upload] │ [Nach rechts │ [In Abrechnung │
|
||
│ │ ziehen] │ übernehmen] │
|
||
└───────────────┴───────────────┴─────────────────────────────┘
|
||
Drag & Drop: Rechnung von "Eingang" → "In Prüfung" → "Verbucht"
|
||
|
||
Der Clou: Wenn du auf eine Karte klickst, öffnet sich rechts das PDF + das Extraktions-Formular (die roten/gelben Felder aus unserer UI).
|
||
|
||
🎯 Dein erster konkreter Schritt (heute noch machbar)
|
||
Ordner-Struktur anlegen:
|
||
|
||
Einklappen
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
/data/documents/2024/
|
||
/data/documents/2023/
|
||
/data/db/steuer.db
|
||
Simples Python-Script oder Node.js:
|
||
Watched den Upload-Ordner
|
||
Macht OCR (Tesseract)
|
||
Speichert in SQLite
|
||
Zeigt Web-Interface (ein einfaches HTML mit Suche)
|
||
Deine ersten 10 PDFs vom Schreibtisch einscannen und testen
|
||
Nach 2 Stunden Arbeit hast du schon:
|
||
|
||
Zentrale Suche über alle Belege
|
||
Keine Excel-Links mehr zu kaputten Pfaden
|
||
Übersicht: "Was habe ich schon, was fehlt noch?"
|
||
Soll ich dir ein minimal funktionierendes Python-Script (oder Node.js) schreiben, das genau das macht? Upload → OCR → SQLite → Simple Web-Suche?
|
||
|
||
Das wäre dein "MVP des MVP" – ohne Docker, ohne Ollama, ohne komplexe Architektur. Nur: Nie wieder nach PDFs suchen.
|
||
|
||
Das klingt nach einem Ansatz wir bauen bei der Nutzung. Das klingt gut
|
||
|
||
profile
|
||
kimi-k2.5:cloud
|
||
Nachgedacht für 11 Sekunden
|
||
Perfekt! Das ist der "Walking Skeleton" Ansatz – das dünneste System, das trotzdem durchläuft (Upload → Speichern → Anzeigen). Kein Over-Engineering, sondern funktionierende Software in 30 Minuten.
|
||
|
||
🚀 Der "Build as you use" Start (heute Abend machbar)
|
||
Technik-Stack für den ersten Abend:
|
||
|
||
Node.js (ein einfaches Script, kein Framework-Wahnsinn)
|
||
SQLite (eine Datei, kein Server)
|
||
HTML + Vanilla JS (kein React-Build-Prozess)
|
||
Kein Docker erstmal (kommt später, wenn es läuft)
|
||
Schritt 0: Die "Leiche" (Walking Skeleton)
|
||
Erstelle eine Datei server.js:
|
||
|
||
javascript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
32
|
||
33
|
||
34
|
||
35
|
||
36
|
||
37
|
||
38
|
||
39
|
||
40
|
||
41
|
||
42
|
||
43
|
||
44
|
||
45
|
||
46
|
||
47
|
||
48
|
||
49
|
||
50
|
||
51
|
||
52
|
||
53
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
const express = require('express');
|
||
const multer = require('multer');
|
||
const sqlite3 = require('sqlite3').verbose();
|
||
const path = require('path');
|
||
|
||
const app = express();
|
||
const upload = multer({ dest: 'uploads/' });
|
||
|
||
// SQLite DB (eine Datei!)
|
||
const db = new sqlite3.Database('./steuer.db');
|
||
|
||
// Tabelle erstellen (falls nicht da)
|
||
db.run(`
|
||
CREATE TABLE IF NOT EXISTS documents (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
filename TEXT,
|
||
originalname TEXT,
|
||
upload_date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
year INTEGER,
|
||
category TEXT,
|
||
amount REAL,
|
||
notes TEXT
|
||
)
|
||
`);
|
||
|
||
// Statische Dateien (Frontend)
|
||
app.use(express.static('public'));
|
||
app.use(express.json());
|
||
|
||
// Upload-Endpunkt
|
||
app.post('/upload', upload.single('file'), (req, res) => {
|
||
const { year, category, amount, notes } = req.body;
|
||
|
||
db.run(
|
||
`INSERT INTO documents (filename, originalname, year, category, amount, notes)
|
||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||
[req.file.filename, req.file.originalname, year, category, amount, notes],
|
||
function(err) {
|
||
if (err) return res.status(500).json({ error: err.message });
|
||
res.json({ id: this.lastID, message: 'Gespeichert' });
|
||
}
|
||
);
|
||
});
|
||
|
||
// Alle Dokumente laden
|
||
app.get('/documents', (req, res) => {
|
||
db.all('SELECT * FROM documents ORDER BY upload_date DESC', [], (err, rows) => {
|
||
if (err) return res.status(500).json({ error: err.message });
|
||
res.json(rows);
|
||
});
|
||
});
|
||
|
||
app.listen(3000, () => console.log('Läuft auf http://localhost:3000'));
|
||
Schritt 1: Das hässliche aber funktionierende Frontend
|
||
Erstelle Ordner public/ und darin index.html:
|
||
|
||
html
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
|
||
Vorschau
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
32
|
||
33
|
||
34
|
||
35
|
||
36
|
||
37
|
||
38
|
||
39
|
||
40
|
||
41
|
||
42
|
||
43
|
||
44
|
||
45
|
||
46
|
||
47
|
||
48
|
||
49
|
||
50
|
||
51
|
||
52
|
||
53
|
||
54
|
||
55
|
||
56
|
||
57
|
||
58
|
||
59
|
||
60
|
||
61
|
||
62
|
||
63
|
||
64
|
||
65
|
||
66
|
||
67
|
||
68
|
||
69
|
||
70
|
||
71
|
||
72
|
||
73
|
||
74
|
||
75
|
||
76
|
||
77
|
||
78
|
||
79
|
||
80
|
||
81
|
||
82
|
||
83
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>Meine Steuer-Docs</title>
|
||
<style>
|
||
body { font-family: Arial; max-width: 800px; margin: 0 auto; padding: 20px; }
|
||
.doc-row { border: 1px solid #ddd; padding: 10px; margin: 10px 0; border-radius: 4px; }
|
||
.doc-row:hover { background: #f5f5f5; }
|
||
input, select { margin: 5px; padding: 5px; }
|
||
button { background: #007bff; color: white; border: none; padding: 10px 20px; cursor: pointer; }
|
||
.search { width: 100%; padding: 10px; margin-bottom: 20px; font-size: 16px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>📁 Steuer-Dokumente</h1>
|
||
|
||
<!-- Upload -->
|
||
<div style="background: #f0f0f0; padding: 20px; margin-bottom: 20px;">
|
||
<h3>Neues Dokument</h3>
|
||
<form id="uploadForm" enctype="multipart/form-data">
|
||
<input type="file" name="file" required><br>
|
||
<input type="number" name="year" placeholder="Jahr (2024)" value="2024">
|
||
<select name="category">
|
||
<option value="hausmeister">Hausmeister</option>
|
||
<option value="heizung">Heizung</option>
|
||
<option value="versicherung">Versicherung</option>
|
||
<option value="steuer">Steuer</option>
|
||
<option value="sonstiges">Sonstiges</option>
|
||
</select>
|
||
<input type="number" step="0.01" name="amount" placeholder="Betrag (€)">
|
||
<input type="text" name="notes" placeholder="Notizen" style="width: 300px;">
|
||
<button type="submit">Speichern</button>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Suche -->
|
||
<input type="text" class="search" id="search" placeholder="Suchen..." onkeyup="loadDocs()">
|
||
|
||
<!-- Liste -->
|
||
<div id="docList"></div>
|
||
|
||
<script>
|
||
// Laden bei Start
|
||
loadDocs();
|
||
|
||
// Upload
|
||
document.getElementById('uploadForm').onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
const formData = new FormData(e.target);
|
||
await fetch('/upload', { method: 'POST', body: formData });
|
||
e.target.reset();
|
||
loadDocs();
|
||
};
|
||
|
||
// Dokumente laden & anzeigen
|
||
async function loadDocs() {
|
||
const search = document.getElementById('search').value.toLowerCase();
|
||
const res = await fetch('/documents');
|
||
const docs = await res.json();
|
||
|
||
const filtered = docs.filter(d =>
|
||
(d.originalname || '').toLowerCase().includes(search) ||
|
||
(d.notes || '').toLowerCase().includes(search) ||
|
||
(d.category || '').includes(search)
|
||
);
|
||
|
||
document.getElementById('docList').innerHTML = filtered.map(d => `
|
||
<div class="doc-row">
|
||
<strong>${d.originalname}</strong>
|
||
<span style="color: #666; float: right;">
|
||
${d.year} | ${d.category} | ${d.amount ? d.amount + '€' : 'kein Betrag'}
|
||
</span>
|
||
<br>
|
||
<small style="color: #999;">${d.notes || 'keine Notizen'}</small>
|
||
<br>
|
||
<a href="/uploads/${d.filename}" target="_blank">PDF ansehen</a>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|
||
Installation & Start (5 Minuten):
|
||
bash
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
mkdir steuer-app
|
||
cd steuer-app
|
||
npm init -y
|
||
npm install express multer sqlite3
|
||
mkdir uploads public
|
||
# kopiere server.js und public/index.html
|
||
node server.js
|
||
Ergebnis: Du hast jetzt eine Seite auf localhost:3000 wo du PDFs hochlädst, mit Jahr/Kategorie/Betrag taggst, und danach suchen kannst.
|
||
|
||
🔄 Die nächsten Iterationen (wenn du es nutzt)
|
||
Iteration 2 (wenn du merkst, dass Beträge wichtig sind):
|
||
|
||
Automatische Summen pro Kategorie ("Wie viel Heizung 2024?")
|
||
Einfaches Dashboard mit Balken
|
||
Iteration 3 (wenn OCR notwendig wird):
|
||
|
||
Tesseract.js im Browser hinzufügen
|
||
Upload zeigt sofort den OCR-Text neben dem PDF
|
||
Du kopierst den Betrag raus (manuell), aber die Suche funktioniert schon über den OCR-Text
|
||
Iteration 4 (wenn du die Abrechnung machst):
|
||
|
||
"Projekte" oder "Abrechnungen" hinzufügen
|
||
Checkboxen: "Diese Rechnung gehört zur Abrechnung 2024 Objekt A"
|
||
Export als CSV für Excel (damit du deinen Steuerberater füttern kannst)
|
||
Iteration 5+ (wenn Basics laufen):
|
||
|
||
Dann Docker, dann Ollama, dann Automatisierung
|
||
🎯 Das "Build as you use" Prinzip
|
||
Regel: Nach jeder Iteration musst du die Software echt nutzen können für deine nächste Buchhaltungs-Aufgabe. Wenn nicht, war sie zu groß.
|
||
|
||
Beispiel:
|
||
|
||
❌ Falsch: "Erst baue ich 3 Monate das perfekte System, dann benutze ich es"
|
||
✅ Richtig: "Heute speichere ich damit 5 Rechnungen, nächste Woche suche ich damit statt in Ordnern"
|
||
Technische Schuld ist erlaubt:
|
||
|
||
Hartcodierte Steuersätze? Egal, ändert sich eh nur jährlich
|
||
Keine Tests? Egal, du merkst ja wenn es kaputt ist
|
||
SQLite statt PostgreSQL? Perfekt, eine Datei, einfach backupbar
|
||
Wichtig: Wenn du merkst, dass du etwas brauchst (z.B. "Shit, ich muss das PDF auch öffnen können"), baust du es dann, nicht vorher.
|
||
|
||
Soll ich dir für Iteration 2 (die Summen/Dashboard) schon den Code vorbereiten, oder willst du erstmal mit dem Skeleton spielen und mir sagen, was als erstes weh tut?
|
||
|
||
Warum nicht direkt in Docker entwickeln? Das Stelle ich mit als einzig pragmatischen weg vor.
|
||
|
||
Und auch die GUI. Vielleicht nicht direkt ein Traum, aber hässlich muss ja nicht sein
|
||
|
||
profile
|
||
kimi-k2.5:cloud
|
||
Heute um 20:05
|
||
Nachgedacht für 16 Sekunden
|
||
Du hast absolut recht! Wenn du mit Docker vertraut bist, ist das der professionelle Weg – und es verhindert späteres "Läuft auf meinem Rechner"-Chaos. Und eine hässliche GUI motiviert niemanden, sie zu nutzen.
|
||
|
||
Hier ist der "Docker-First + schönes UI" Stack:
|
||
|
||
🐳 Docker-Dev-Setup (mit Hot-Reload)
|
||
Vorteil: Du entwickelst direkt im Container, aber mit Instant-Reload beim Code-Ändern.
|
||
|
||
Projektstruktur
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
steuer-app/
|
||
├── docker-compose.yml
|
||
├── Dockerfile.dev # Für Entwicklung mit Hot-Reload
|
||
├── backend/
|
||
│ ├── package.json
|
||
│ ├── server.js # Express API
|
||
│ └── uploads/ # Gemountetes Volume
|
||
└── frontend/
|
||
├── package.json
|
||
├── vite.config.js # Vite (schneller als CRA)
|
||
├── tailwind.config.js # Schönes UI
|
||
└── src/
|
||
├── App.jsx
|
||
└── components/
|
||
1. Backend (Docker + Express + SQLite)
|
||
dockerfile
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
# Dockerfile.dev
|
||
FROM node:20-alpine
|
||
WORKDIR /app
|
||
RUN npm install -g nodemon
|
||
COPY package*.json ./
|
||
RUN npm install
|
||
COPY . .
|
||
EXPOSE 3000
|
||
CMD ["nodemon", "--legacy-watch", "server.js"]
|
||
javascript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
32
|
||
33
|
||
34
|
||
35
|
||
36
|
||
37
|
||
38
|
||
39
|
||
40
|
||
41
|
||
42
|
||
43
|
||
44
|
||
45
|
||
46
|
||
47
|
||
48
|
||
49
|
||
50
|
||
51
|
||
52
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
// backend/server.js
|
||
const express = require('express');
|
||
const multer = require('multer');
|
||
const sqlite3 = require('sqlite3');
|
||
const cors = require('cors');
|
||
const path = require('path');
|
||
|
||
const app = express();
|
||
app.use(cors());
|
||
app.use(express.json());
|
||
|
||
// Upload-Config
|
||
const storage = multer.diskStorage({
|
||
destination: (req, file, cb) => cb(null, '/app/uploads'),
|
||
filename: (req, file, cb) => cb(null, Date.now() + '-' + file.originalname)
|
||
});
|
||
const upload = multer({ storage });
|
||
|
||
// DB
|
||
const db = new sqlite3.Database('/app/data/steuer.db');
|
||
db.run(`
|
||
CREATE TABLE IF NOT EXISTS docs (
|
||
id INTEGER PRIMARY KEY,
|
||
filename TEXT, originalname TEXT, year INTEGER,
|
||
category TEXT, amount REAL, notes TEXT,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
`);
|
||
|
||
// Routes
|
||
app.get('/api/docs', (req, res) => {
|
||
db.all('SELECT * FROM docs ORDER BY created_at DESC', [], (err, rows) => {
|
||
res.json(rows || []);
|
||
});
|
||
});
|
||
|
||
app.post('/api/upload', upload.single('file'), (req, res) => {
|
||
const { year, category, amount, notes } = req.body;
|
||
db.run(
|
||
'INSERT INTO docs (filename, originalname, year, category, amount, notes) VALUES (?, ?, ?, ?, ?, ?)',
|
||
[req.file.filename, req.file.originalname, year, category, amount, notes],
|
||
function(err) {
|
||
if (err) return res.status(500).json({ error: err });
|
||
res.json({ id: this.lastID });
|
||
}
|
||
);
|
||
});
|
||
|
||
// Statische Dateien für Uploads
|
||
app.use('/uploads', express.static('/app/uploads'));
|
||
|
||
app.listen(3000, () => console.log('API läuft auf :3000'));
|
||
json
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
⌄
|
||
⌄
|
||
// backend/package.json
|
||
{
|
||
"dependencies": {
|
||
"express": "^4.18.2",
|
||
"multer": "^1.4.5-lts.1",
|
||
"sqlite3": "^5.1.6",
|
||
"cors": "^2.8.5"
|
||
}
|
||
}
|
||
2. Frontend (React + Vite + Tailwind - Schnell & Hübsch)
|
||
dockerfile
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
# frontend/Dockerfile.dev
|
||
FROM node:20-alpine
|
||
WORKDIR /app
|
||
COPY package*.json ./
|
||
RUN npm install
|
||
COPY . .
|
||
EXPOSE 5173
|
||
CMD ["npm", "run", "dev", "--", "--host"]
|
||
json
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
// frontend/package.json
|
||
{
|
||
"scripts": {
|
||
"dev": "vite",
|
||
"build": "vite build"
|
||
},
|
||
"dependencies": {
|
||
"react": "^18.2.0",
|
||
"react-dom": "^18.2.0",
|
||
"lucide-react": "^0.294.0"
|
||
},
|
||
"devDependencies": {
|
||
"@vitejs/plugin-react": "^4.2.0",
|
||
"autoprefixer": "^10.4.16",
|
||
"postcss": "^8.4.32",
|
||
"tailwindcss": "^3.3.6",
|
||
"vite": "^5.0.8"
|
||
}
|
||
}
|
||
javascript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
⌄
|
||
// frontend/tailwind.config.js
|
||
/** @type {import('tailwindcss').Config} */
|
||
export default {
|
||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||
theme: { extend: {} },
|
||
plugins: [],
|
||
}
|
||
css
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
⌄
|
||
/* frontend/src/index.css */
|
||
@tailwind base;
|
||
@tailwind components;
|
||
@tailwind utilities;
|
||
|
||
body {
|
||
@apply bg-gray-50 text-gray-900;
|
||
}
|
||
javascript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
⌄
|
||
⌄
|
||
⌄
|
||
// frontend/vite.config.js
|
||
import { defineConfig } from 'vite'
|
||
import react from '@vitejs/plugin-react'
|
||
|
||
export default defineConfig({
|
||
plugins: [react()],
|
||
server: {
|
||
host: '0.0.0.0',
|
||
port: 5173,
|
||
proxy: {
|
||
'/api': 'http://backend:3000',
|
||
'/uploads': 'http://backend:3000'
|
||
}
|
||
}
|
||
})
|
||
3. Schöne React-Components (nicht hässlich!)
|
||
jsx
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
73
|
||
74
|
||
75
|
||
76
|
||
77
|
||
78
|
||
79
|
||
80
|
||
81
|
||
82
|
||
83
|
||
84
|
||
85
|
||
86
|
||
87
|
||
88
|
||
89
|
||
90
|
||
91
|
||
92
|
||
93
|
||
94
|
||
95
|
||
96
|
||
97
|
||
98
|
||
99
|
||
100
|
||
101
|
||
102
|
||
103
|
||
104
|
||
105
|
||
106
|
||
107
|
||
108
|
||
109
|
||
110
|
||
111
|
||
112
|
||
113
|
||
114
|
||
115
|
||
116
|
||
117
|
||
118
|
||
119
|
||
120
|
||
121
|
||
122
|
||
123
|
||
124
|
||
125
|
||
126
|
||
127
|
||
128
|
||
129
|
||
130
|
||
131
|
||
132
|
||
133
|
||
134
|
||
135
|
||
136
|
||
137
|
||
138
|
||
139
|
||
140
|
||
141
|
||
142
|
||
143
|
||
144
|
||
145
|
||
146
|
||
147
|
||
148
|
||
149
|
||
150
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
// frontend/src/App.jsx
|
||
<select name="year" defaultValue={2024} className="w-full rounded-lg border-gray-300 border p-2">
|
||
{[2024, 2023, 2022].map(y => <option key={y} value={y}>{y}</option>)}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||
<select name="category" className="w-full rounded-lg border-gray-300 border p-2">
|
||
<option value="hausmeister">Hausmeister</option>
|
||
<option value="heizung">Heizung</option>
|
||
<option value="versicherung">Versicherung</option>
|
||
<option value="steuer">Steuer</option>
|
||
<option value="sonstiges">Sonstiges</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Betrag (€)</label>
|
||
<input type="number" step="0.01" name="amount" placeholder="0.00"
|
||
className="w-full rounded-lg border-gray-300 border p-2" />
|
||
</div>
|
||
|
||
<button type="submit" disabled={uploading}
|
||
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition disabled:opacity-50">
|
||
{uploading ? 'Speichern...' : 'Hochladen'}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
|
||
{/* Search */}
|
||
<div className="relative mb-6">
|
||
<Search className="absolute left-3 top-3 w-5 h-5 text-gray-400" />
|
||
<input type="text" placeholder="Dokumente durchsuchen..." value={search} onChange={e => setSearch(e.target.value)}
|
||
className="w-full pl-10 pr-4 py-3 rounded-xl border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition" />
|
||
</div>
|
||
|
||
{/* Document List */}
|
||
<div className="grid gap-4">
|
||
{filtered.map(doc => (
|
||
<div key={doc.id} className="bg-white rounded-xl shadow-sm hover:shadow-md transition p-5 border border-gray-100">
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-3 mb-2">
|
||
<FileText className="w-5 h-5 text-blue-600" />
|
||
<h3 className="font-semibold text-gray-900">{doc.originalname}</h3>
|
||
<span className="px-3 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700">
|
||
{doc.category}
|
||
</span>
|
||
</div>
|
||
|
||
{doc.notes && <p className="text-gray-600 text-sm mb-3">{doc.notes}</p>}
|
||
|
||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||
<span className="flex items-center gap-1">
|
||
<Calendar className="w-4 h-4" /> {doc.year}
|
||
</span>
|
||
{doc.amount && (
|
||
<span className="flex items-center gap-1 text-green-700 font-medium">
|
||
<DollarSign className="w-4 h-4" /> {parseFloat(doc.amount).toFixed(2)} €
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<a href={`/uploads/${doc.filename}`} target="_blank"
|
||
className="text-blue-600 hover:text-blue-800 text-sm font-medium px-4 py-2 rounded-lg hover:bg-blue-50 transition">
|
||
Ansehen →
|
||
</a>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default App;
|
||
4. Docker Compose (Dev-Version mit Hot-Reload)
|
||
yaml
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
32
|
||
33
|
||
34
|
||
35
|
||
36
|
||
37
|
||
38
|
||
39
|
||
40
|
||
41
|
||
42
|
||
43
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
# docker-compose.yml
|
||
version: '3.8'
|
||
|
||
services:
|
||
backend:
|
||
build:
|
||
context: ./backend
|
||
dockerfile: Dockerfile.dev
|
||
volumes:
|
||
- ./backend:/app # Hot-reload für Code
|
||
- ./backend/node_modules:/app/node_modules # Node_modules nicht überschreiben
|
||
- uploads:/app/uploads # Persistente Uploads
|
||
- db_data:/app/data # Persistente DB
|
||
ports:
|
||
- "3000:3000"
|
||
environment:
|
||
- NODE_ENV=development
|
||
networks:
|
||
- app-net
|
||
|
||
frontend:
|
||
build:
|
||
context: ./frontend
|
||
dockerfile: Dockerfile.dev
|
||
volumes:
|
||
- ./frontend:/app
|
||
- /app/node_modules # Anonymous volume für node_modules
|
||
ports:
|
||
- "5173:5173"
|
||
environment:
|
||
- CHOKIDAR_USEPOLLING=true # Für Hot-reload in Docker
|
||
networks:
|
||
- app-net
|
||
depends_on:
|
||
- backend
|
||
|
||
volumes:
|
||
uploads:
|
||
db_data:
|
||
|
||
networks:
|
||
app-net:
|
||
driver: bridge
|
||
🚀 Starten
|
||
bash
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
mkdir steuer-app && cd steuer-app
|
||
# Ordner erstellen wie oben beschrieben
|
||
# Dateien kopieren
|
||
|
||
docker-compose up --build
|
||
Ergebnis:
|
||
|
||
Frontend: http://localhost:5173 (React + Tailwind, schick!)
|
||
Backend: http://localhost:3000 (API)
|
||
Hot-Reload: Code ändern → sofort sichtbar im Browser
|
||
Persistenz: Uploads und DB bleiben auch bei docker-compose down erhalten
|
||
Das ist der professionelle Weg – und es sieht gleich aus wie moderne SaaS-Apps (Notion/Linear-Stil). Ist das der Richtige Ansatz für dich? |