commit a25b234405e0b8b205397c9ff2bcf84bacd04de9 Author: root Date: Sat May 9 05:58:21 2026 +0000 Initial: Privacy Gateway Projekt mit Team-Implementierung diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 0000000..1170a64 --- /dev/null +++ b/PROJECT.md @@ -0,0 +1,63 @@ +# Privacy Gateway - Projektübersicht + +## Vision +Ein Docker-basierter Proxy für KI-Prompts, der automatisch persönliche Daten (PII) anonymisiert, an externe KI-Modelle weiterleitet und die Antworten re-identifiziert. + +## Kernfunktionen +1. **Multi-Chat-Interface** - Mehrere parallele Konversationen (wie OpenWebUI) +2. **PII-Erkennung & Anonymisierung** - Lokales LLM für Datenschutz +3. **KI-Proxy** - Weiterleitung an Ollama/OpenWebUI +4. **Re-Identifizierung** - Zurückfüllen der Originaldaten in Antworten +5. **Session-Management** - Persistente Chats mit History + +## Technologie-Stack +- **Frontend:** React + TypeScript (Chat-UI) +- **Backend:** Node.js/Express oder Python/FastAPI +- **Anonymisierung:** Ollama mit lokalem Modell (z.B. gemma oder mistral) +- **Datenbank:** PostgreSQL (Sessions, Mappings) +- **Container:** Docker + Docker Compose +- **API-Proxy:** Eigener Ollama-kompatibler Endpoint + +## Architektur +``` +┌─────────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ +│ Client │────▶│ Gateway │────▶│ Anonymizer │────▶│ Ollama │ +│ (Chat-UI) │◄────│ (API) │◄────│ (lokales LLM)│◄────│ (KI-Model) │ +└─────────────┘ └──────────────┘ └──────────────┘ └─────────────┘ + │ + ▼ + ┌──────────────┐ + │ PostgreSQL │ + │ (Mapping DB) │ + └──────────────┘ +``` + +## PII-Kategorien (Phase 1) +- Namen (Personen, Firmen) +- Adressen +- E-Mail-Adressen +- Telefonnummern +- Geburtsdaten +- Kontonummern / IBAN +- Personalnummern +- Kreditkartennummern + +## Projektphasen +1. **Setup** - Grundstruktur, Docker, Datenbank +2. **Anonymisierung** - PII-Erkennung mit lokalem LLM +3. **Backend API** - Proxy-Endpoint, Session-Management +4. **Frontend** - Chat-UI mit Multi-Window +5. **Integration** - Mapping-Speicherung, Re-Identifizierung +6. **Testing & Polish** - E2E-Tests, Feinschliff + +## Team +- **Peter (ich)** - Projektleitung, Architektur, Integration +- **Sub-Agent: Backend-Dev** - API, Datenbank, Ollama-Proxy +- **Sub-Agent: Frontend-Dev** - React-UI, Chat-Komponenten +- **Sub-Agent: Anonymisierung** - PII-Erkennung, Prompt-Engineering + +## Start-Datum +2026-05-09 + +## Status +🟡 Planung - Team wird zusammengestellt diff --git a/anonymizer/Dockerfile b/anonymizer/Dockerfile new file mode 100644 index 0000000..4453c3a --- /dev/null +++ b/anonymizer/Dockerfile @@ -0,0 +1,47 @@ +# Dockerfile für den Anonymizer Service +# Läuft im Backend-Container oder standalone + +FROM node:20-alpine AS base + +# Installiere nötige Pakete +RUN apk add --no-cache dumb-init + +WORKDIR /app + +# Kopiere Package-Dateien +COPY package.json tsconfig.json ./ + +# Installiere Dependencies +RUN npm ci --only=production + +# Dev-Stage für Entwicklung +FROM base AS dev + +RUN npm ci + +COPY . . + +EXPOSE 3000 + +CMD ["dumb-init", "npm", "run", "dev"] + +# Production-Stage +FROM base AS production + +# Kopiere kompilierte Dateien (von Build-Stage) +COPY --from=builder /app/dist ./dist + +USER node + +EXPOSE 3000 + +CMD ["dumb-init", "node", "dist/index.js"] + +# Builder-Stage (wird für Production benötigt) +FROM base AS builder + +RUN npm ci + +COPY . . + +RUN npm run build diff --git a/anonymizer/README.md b/anonymizer/README.md new file mode 100644 index 0000000..b558d65 --- /dev/null +++ b/anonymizer/README.md @@ -0,0 +1,161 @@ +# 🔐 Privacy Gateway Anonymizer + +PII-Erkennungs- und Anonymisierungs-Service für das Privacy Gateway. +Nutzt lokale LLMs (Ollama) zur Erkennung persönlicher Daten mit Pattern-basiertem Fallback. + +## Features + +- 🤖 **LLM-basierte PII-Erkennung** via Ollama +- 🛡️ **10 PII-Typen** erkannt: Namen, Adressen, E-Mails, Telefonnummern, IBANs, etc. +- 🔄 **Re-Identifizierung** für nachgelagerte Prozesse +- ⚡ **Pattern-Fallback** bei LLM-Ausfall +- 📊 **Sensitivitäts-Bewertung** (low/medium/high/critical) +- 💾 **Mapping-Serialisierung** für Persistenz + +## Schnellstart + +```typescript +import { anonymize, reIdentify } from './index.js'; + +// Anonymisieren +const result = await anonymize( + 'Hallo Max Mustermann, kontaktiere mich unter max@beispiel.de' +); + +console.log(result.anonymizedText); +// "Hallo [NAME_1], kontaktiere mich unter [EMAIL_1]" + +// Re-Identifizieren +const restored = reIdentify(result.anonymizedText, result.mapping); +console.log(restored.reidentifiedText); +// "Hallo Max Mustermann, kontaktiere mich unter max@beispiel.de" +``` + +## Unterstützte PII-Typen + +| Typ | Beschreibung | Beispiel | Sensitivität | +|-----|-------------|----------|--------------| +| `name_person` | Personenname | Max Mustermann | 🔴 high | +| `name_company` | Firmenname | Musterfirma GmbH | 🟡 medium | +| `address` | Vollständige Adresse | Musterstraße 1, 12345 Berlin | 🔴 high | +| `email` | E-Mail-Adresse | max@beispiel.de | 🔴 high | +| `phone` | Telefonnummer (DE) | +49 170 12345678 | 🟡 medium | +| `birthdate` | Geburtsdatum | 15.03.1985 | 🔴 high | +| `account_number` | Kontonummer | 1234567890 | 🔴 critical | +| `iban` | IBAN | DE89 3704... | 🔴 critical | +| `employee_id` | Personalnummer | EMP-12345 | 🟡 medium | +| `credit_card` | Kreditkartennummer | 4111 1111... | 🔴 critical | + +## API-Referenz + +### `anonymize(text, config?)` + +Anonymisiert einen Text durch Erkennung und Ersetzung von PII. + +**Parameter:** +- `text` (string): Zu anonymisierender Text +- `config` (optional): Konfigurationsobjekt + +**Rückgabe:** `AnonymizeResult` +```typescript +{ + success: boolean; + anonymizedText: string; + mapping: ReverseMapping; + sensitivityLevel: 'low' | 'medium' | 'high' | 'critical'; + piiCount: number; + processingTimeMs: number; + error?: string; + usedFallback?: boolean; +} +``` + +### `reIdentify(anonymizedText, mapping, options?)` + +Stellt einen anonymisierten Text wieder her. + +**Parameter:** +- `anonymizedText` (string): Anonymisierter Text mit Platzhaltern +- `mapping` (ReverseMapping): Mapping von Platzhalter zu Originalwert +- `options` (optional): Optionen für die Re-Identifizierung + +**Rückgabe:** `ReidentifyResult` +```typescript +{ + success: boolean; + reidentifiedText: string; + replacementsMade: number; + errors: string[]; +} +``` + +### `createAnonymizer(config?)` + +Erstellt eine konfigurierte Anonymizer-Instanz. + +```typescript +const anonymizer = createAnonymizer({ + ollamaUrl: 'http://localhost:11434', + model: 'llama3.2', + timeoutMs: 30000, + maxRetries: 2, + fallbackEnabled: true, +}); +``` + +## Konfiguration + +Umgebungsvariablen: +```bash +OLLAMA_URL=http://192.168.2.122:11434 +OLLAMA_MODEL=llama3.2 +OLLAMA_TIMEOUT=30000 +``` + +## Integration im Backend + +```typescript +import { getAnonymizer, maskForLog } from '@privacy-gateway/anonymizer'; + +const anonymizer = getAnonymizer(); + +// Vor dem Senden an externe KI +const { anonymizedText, mapping, sensitivityLevel } = await anonymizer.anonymize(userInput); + +// Log mit maskierten Werten +console.log(`Sensitivität: ${sensitivityLevel}, PII: ${piiCount}`); + +// Nach Antwort der KI +const restored = anonymizer.reIdentify(aiResponse, mapping); +``` + +## Tests + +```bash +npm test +``` + +## Projektstruktur + +``` +anonymizer/ +├── src/ +│ ├── index.ts # Haupt-Export +│ ├── anonymizer.ts # Kern-Logik +│ ├── reverser.ts # Re-Identifizierung +│ ├── pii-types.ts # Typ-Definitionen +│ ├── prompts/ +│ │ └── pii-detection.ts # LLM-Prompts +│ ├── utils/ +│ │ └── text.ts # Hilfsfunktionen +│ └── test/ +│ └── test-cases.ts # Testfälle +├── package.json +├── tsconfig.json +├── Dockerfile +└── README.md +``` + +## Lizenz + +MIT diff --git a/anonymizer/package.json b/anonymizer/package.json new file mode 100644 index 0000000..c3851ff --- /dev/null +++ b/anonymizer/package.json @@ -0,0 +1,38 @@ +{ + "name": "@privacy-gateway/anonymizer", + "version": "1.0.0", + "description": "PII-Erkennungs- und Anonymisierungs-Service für das Privacy Gateway", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "test": "node --test dist/test/*.js", + "lint": "eslint src/**/*.ts", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "pii", + "anonymization", + "privacy", + "gdpr", + "dsgvo", + "ollama", + "llm" + ], + "author": "Privacy Gateway Team", + "license": "MIT", + "dependencies": { + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.0.0", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/anonymizer/src/anonymizer.ts b/anonymizer/src/anonymizer.ts new file mode 100644 index 0000000..825c68c --- /dev/null +++ b/anonymizer/src/anonymizer.ts @@ -0,0 +1,271 @@ +/** + * Haupt-Logik für PII-Erkennung und Anonymisierung + * Interagiert mit Ollama für LLM-basierte PII-Erkennung + */ + +import { + PiiType, + PiiInstance, + PiiDetectionResult, + ReverseMapping, + PII_TYPE_METADATA, +} from './pii-types.js'; +import { + generatePlaceholder, + replaceInText, + cleanJsonResponse, + validatePiiResult, + calculateSensitivityLevel, + mightContainPii, +} from './utils/text.js'; +import { generatePrompt, PromptConfig } from './prompts/pii-detection.js'; + +export interface AnonymizerConfig { + ollamaUrl: string; + model: string; + timeoutMs: number; + maxRetries: number; + fallbackEnabled: boolean; +} + +export const DEFAULT_CONFIG: AnonymizerConfig = { + ollamaUrl: process.env.OLLAMA_URL || 'http://192.168.2.122:11434', + model: process.env.OLLAMA_MODEL || 'llama3.2', + timeoutMs: parseInt(process.env.OLLAMA_TIMEOUT || '30000', 10), + maxRetries: 2, + fallbackEnabled: true, +}; + +export interface AnonymizeResult { + success: boolean; + anonymizedText: string; + mapping: ReverseMapping; + sensitivityLevel: 'low' | 'medium' | 'high' | 'critical'; + piiCount: number; + processingTimeMs: number; + error?: string; + usedFallback?: boolean; +} + +/** + * Hauptklasse für die PII-Anonymisierung + */ +export class Anonymizer { + private config: AnonymizerConfig; + private placeholderCounters: Map; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + this.placeholderCounters = new Map(); + } + + /** + * Anonymisiert einen Text durch PII-Erkennung und Ersetzung + */ + async anonymize(text: string): Promise { + const startTime = Date.now(); + + // Schneller Vorab-Check + if (!mightContainPii(text)) { + return { + success: true, + anonymizedText: text, + mapping: {}, + sensitivityLevel: 'low', + piiCount: 0, + processingTimeMs: Date.now() - startTime, + }; + } + + try { + // Versuche LLM-basierte Erkennung + const result = await this.anonymizeWithLlm(text); + const processingTimeMs = Date.now() - startTime; + + return { + success: true, + anonymizedText: result.anonymizedText, + mapping: this.buildReverseMapping(result.piiFound), + sensitivityLevel: calculateSensitivityLevel(result.piiFound), + piiCount: result.piiFound.length, + processingTimeMs, + }; + } catch (error) { + // Fallback: Pattern-basierte Erkennung + if (this.config.fallbackEnabled) { + const fallbackResult = this.anonymizeWithPatterns(text); + const processingTimeMs = Date.now() - startTime; + + return { + success: true, + anonymizedText: fallbackResult.anonymizedText, + mapping: this.buildReverseMapping(fallbackResult.piiFound), + sensitivityLevel: calculateSensitivityLevel(fallbackResult.piiFound), + piiCount: fallbackResult.piiFound.length, + processingTimeMs, + usedFallback: true, + }; + } + + return { + success: false, + anonymizedText: text, + mapping: {}, + sensitivityLevel: 'low', + piiCount: 0, + processingTimeMs: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * LLM-basierte PII-Erkennung via Ollama + */ + private async anonymizeWithLlm(text: string): Promise { + const prompt = generatePrompt(text, this.config.model); + + const response = await fetch(`${this.config.ollamaUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: this.config.model, + messages: [ + { role: 'system', content: prompt.system }, + { role: 'user', content: prompt.user }, + ], + stream: false, + options: { + temperature: prompt.config.temperature, + num_predict: prompt.config.maxTokens, + }, + }), + }); + + if (!response.ok) { + throw new Error(`Ollama API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as { message?: { content?: string } }; + const content = data.message?.content || ''; + + return this.parseLlmResponse(content, text); + } + + /** + * Parst die LLM-Antwort und extrahiert PII-Daten + */ + private parseLlmResponse(response: string, originalText: string): PiiDetectionResult { + const cleaned = cleanJsonResponse(response); + + let parsed: unknown; + try { + parsed = JSON.parse(cleaned); + } catch { + // Versuche, JSON aus dem Text zu extrahieren + const jsonMatch = cleaned.match(/\{[\s\S]*\}/); + if (jsonMatch) { + try { + parsed = JSON.parse(jsonMatch[0]); + } catch (e) { + throw new Error(`Failed to parse LLM response: ${e}`); + } + } else { + throw new Error('No valid JSON found in LLM response'); + } + } + + if (!validatePiiResult(parsed)) { + throw new Error('Invalid PII result structure'); + } + + // Normalisiere PII-Liste + const piiFound: PiiInstance[] = parsed.pii_found.map((item: unknown, index: number) => { + const pii = item as Record; + return { + type: pii.type as PiiType, + original: pii.original, + replacement: pii.replacement || generatePlaceholder(pii.type as PiiType, index + 1), + }; + }); + + return { + piiFound, + anonymizedText: parsed.anonymized_text, + originalText, + }; + } + + /** + * Fallback: Pattern-basierte PII-Erkennung + * Wird verwendet, wenn LLM nicht verfügbar ist + */ + private anonymizeWithPatterns(text: string): PiiDetectionResult { + const piiFound: PiiInstance[] = []; + const replacements: Array<{ original: string; replacement: string }> = []; + + // Pattern-basierte Erkennung für bekannte PII-Typen + const patterns: Array<{ type: PiiType; regex: RegExp }> = [ + { type: 'email', regex: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g }, + { type: 'phone', regex: /(?:\+49|0)[\s\-/]?\d{1,4}[\s\-/]?\d{1,4}[\s\-/]?\d{1,4}[\s\-/]?\d{0,4}\b/g }, + { type: 'iban', regex: /\b[A-Z]{2}\d{2}(?:[\s]?\d{4}){4,6}\b/g }, + { type: 'birthdate', regex: /\b(?:0?[1-9]|[12][0-9]|3[01])[\.\/\-](?:0?[1-9]|1[0-2])[\.\/\-](?:19|20)\d{2}\b/g }, + { type: 'credit_card', regex: /\b(?:\d{4}[\s-]?){3,4}\d{1,4}\b/g }, + ]; + + for (const { type, regex } of patterns) { + const matches = text.matchAll(regex); + let counter = 1; + + for (const match of matches) { + const original = match[0]; + + // Vermeide Duplikate + if (replacements.some(r => r.original === original)) continue; + + const placeholder = generatePlaceholder(type, counter++); + piiFound.push({ type, original, replacement: placeholder }); + replacements.push({ original, replacement: placeholder }); + } + } + + const anonymizedText = replaceInText(text, replacements); + + return { + piiFound, + anonymizedText, + originalText: text, + }; + } + + /** + * Baut ein Reverse-Mapping für die Re-Identifizierung + */ + private buildReverseMapping(piiFound: PiiInstance[]): ReverseMapping { + const mapping: ReverseMapping = {}; + + for (const pii of piiFound) { + mapping[pii.replacement] = pii; + } + + return mapping; + } + + /** + * Setzt den Placeholder-Zähler zurück + */ + resetCounters(): void { + this.placeholderCounters.clear(); + } +} + +/** + * Convenience-Funktion für einfache Anonymisierung + */ +export async function anonymizeText( + text: string, + config?: Partial +): Promise { + const anonymizer = new Anonymizer(config); + return anonymizer.anonymize(text); +} diff --git a/anonymizer/src/index.ts b/anonymizer/src/index.ts new file mode 100644 index 0000000..1a15748 --- /dev/null +++ b/anonymizer/src/index.ts @@ -0,0 +1,220 @@ +/** + * Haupt-Export für den Anonymizer Service + * Einfache API für Integration in das Backend + */ + +import { Anonymizer, anonymizeText, AnonymizeResult, AnonymizerConfig } from './anonymizer.js'; +import { + reidentify, + ReidentifyResult, + ReidentifyOptions, + serializeMapping, + deserializeMapping, + validateMapping, + mergeMappings, + extractPlaceholders, + getMappingStats, +} from './reverser.js'; +import { + PiiType, + PiiInstance, + PiiDetectionResult, + ReverseMapping, + PII_TYPE_METADATA, + PII_PATTERNS, +} from './pii-types.js'; +import { + generatePrompt, + generateShortPrompt, + PromptConfig, +} from './prompts/pii-detection.js'; +import { + generatePlaceholder, + replaceInText, + cleanJsonResponse, + calculateSensitivityLevel, + maskPiiForLog, + mightContainPii, + truncateText, +} from './utils/text.js'; + +// ==================== Haupt-API ==================== + +/** + * Einzige Funktion zum Anonymisieren von Text + * Wrapper für einfachen Zugriff aus dem Backend + */ +export async function anonymize( + text: string, + config?: Partial +): Promise { + return anonymizeText(text, config); +} + +/** + * Einzige Funktion zum Re-Identifizieren von Text + * Wrapper für einfachen Zugriff aus dem Backend + */ +export function reIdentify( + anonymizedText: string, + mapping: ReverseMapping, + options?: Partial +): ReidentifyResult { + return reidentify(anonymizedText, mapping, options); +} + +// ==================== Erweiterte API ==================== + +export interface PrivacyGatewayAnonymizer { + // Kern-Funktionen + anonymize(text: string): Promise; + reIdentify(text: string, mapping: ReverseMapping, options?: Partial): ReidentifyResult; + + // Hilfs-Funktionen + validateMapping(mapping: ReverseMapping): { valid: boolean; errors: string[] }; + getMappingInfo(mapping: ReverseMapping): ReturnType; + serialize(mapping: ReverseMapping): string; + deserialize(serialized: string): ReverseMapping; +} + +/** + * Erstellt eine konfigurierte Anonymizer-Instanz + * Für fortgeschrittene Nutzung mit mehreren Konfigurationen + */ +export function createAnonymizer(config?: Partial): PrivacyGatewayAnonymizer { + const instance = new Anonymizer(config); + + return { + anonymize: (text: string) => instance.anonymize(text), + reIdentify: (text: string, mapping: ReverseMapping, options?: Partial) => + reidentify(text, mapping, options), + validateMapping: (mapping: ReverseMapping) => validateMapping(mapping), + getMappingInfo: (mapping: ReverseMapping) => getMappingStats(mapping), + serialize: (mapping: ReverseMapping) => serializeMapping(mapping), + deserialize: (serialized: string) => deserializeMapping(serialized), + }; +} + +// ==================== Integration für Backend ==================== + +/** + * Standard-Konfiguration für das Backend + */ +export const DEFAULT_BACKEND_CONFIG: AnonymizerConfig = { + ollamaUrl: process.env.OLLAMA_URL || 'http://192.168.2.122:11434', + model: process.env.OLLAMA_MODEL || 'llama3.2', + timeoutMs: parseInt(process.env.OLLAMA_TIMEOUT || '30000', 10), + maxRetries: 2, + fallbackEnabled: true, +}; + +/** + * Singleton-Instanz für das Backend + */ +let globalAnonymizer: PrivacyGatewayAnonymizer | null = null; + +/** + * Gibt die globale Anonymizer-Instanz zurück + * Lazy-Initialisierung + */ +export function getAnonymizer(): PrivacyGatewayAnonymizer { + if (!globalAnonymizer) { + globalAnonymizer = createAnonymizer(DEFAULT_BACKEND_CONFIG); + } + return globalAnonymizer; +} + +/** + * Setzt die globale Konfiguration zurück + * Nützlich für Tests oder Konfigurations-Updates + */ +export function resetAnonymizer(): void { + globalAnonymizer = null; +} + +// ==================== Schnelle Hilfsfunktionen ==================== + +/** + * Prüft ob ein Text PII enthalten könnte (schneller Vorab-Check) + */ +export function quickCheck(text: string): boolean { + return mightContainPii(text); +} + +/** + * Gibt Informationen über unterstützte PII-Typen zurück + */ +export function getSupportedPiiTypes(): Array<{ + type: PiiType; + label: string; + description: string; + example: string; + sensitiveLevel: string; +}> { + return Object.values(PII_TYPE_METADATA).map(meta => ({ + type: meta.type, + label: meta.label, + description: meta.description, + example: meta.example, + sensitiveLevel: meta.sensitiveLevel, + })); +} + +/** + * Maskiert einen PII-Wert für Logging + */ +export function maskForLog(value: string, type: PiiType): string { + return maskPiiForLog(value, type); +} + +// ==================== Type-Exports ==================== + +export { + // Haupt-Klassen + Anonymizer, + + // Re-Identifizierung + reidentify, + serializeMapping, + deserializeMapping, + validateMapping, + mergeMappings, + extractPlaceholders, + getMappingStats, + + // Prompts + generatePrompt, + generateShortPrompt, + + // Utils + generatePlaceholder, + replaceInText, + cleanJsonResponse, + calculateSensitivityLevel, + truncateText, + + // Types + AnonymizerConfig, + AnonymizeResult, + ReidentifyResult, + ReidentifyOptions, + PromptConfig, + PiiType, + PiiInstance, + PiiDetectionResult, + ReverseMapping, + PII_TYPE_METADATA, + PII_PATTERNS, +}; + +// ==================== Default Export ==================== + +export default { + anonymize, + reIdentify, + createAnonymizer, + getAnonymizer, + quickCheck, + getSupportedPiiTypes, + maskForLog, +}; diff --git a/anonymizer/src/pii-types.ts b/anonymizer/src/pii-types.ts new file mode 100644 index 0000000..930516d --- /dev/null +++ b/anonymizer/src/pii-types.ts @@ -0,0 +1,146 @@ +/** + * PII-Typen Definitionen für das Privacy Gateway + * Definiert alle erkennbaren persönlichen Informationen und ihre Metadaten + */ + +export type PiiType = + | 'name_person' + | 'name_company' + | 'address' + | 'email' + | 'phone' + | 'birthdate' + | 'account_number' + | 'iban' + | 'employee_id' + | 'credit_card'; + +export interface PiiInstance { + type: PiiType; + original: string; + replacement: string; + startIndex?: number; + endIndex?: number; + confidence?: number; +} + +export interface PiiDetectionResult { + piiFound: PiiInstance[]; + anonymizedText: string; + originalText: string; +} + +export interface ReplacementMap { + [placeholder: string]: string; +} + +export interface ReverseMapping { + [placeholder: string]: PiiInstance; +} + +// Metadaten für jeden PII-Typ +export interface PiiTypeMetadata { + type: PiiType; + label: string; + description: string; + example: string; + prefix: string; + sensitiveLevel: 'low' | 'medium' | 'high' | 'critical'; +} + +export const PII_TYPE_METADATA: Record = { + name_person: { + type: 'name_person', + label: 'Personenname', + description: 'Vor- und Nachname einer natürlichen Person', + example: 'Max Mustermann', + prefix: 'NAME', + sensitiveLevel: 'high', + }, + name_company: { + type: 'name_company', + label: 'Firmenname', + description: 'Name eines Unternehmens oder Organisation', + example: 'Musterfirma GmbH', + prefix: 'COMPANY', + sensitiveLevel: 'medium', + }, + address: { + type: 'address', + label: 'Adresse', + description: 'Straße, Hausnummer, PLZ und Ort', + example: 'Musterstraße 42, 12345 Berlin', + prefix: 'ADDRESS', + sensitiveLevel: 'high', + }, + email: { + type: 'email', + label: 'E-Mail', + description: 'E-Mail-Adresse', + example: 'max@mustermann.de', + prefix: 'EMAIL', + sensitiveLevel: 'high', + }, + phone: { + type: 'phone', + label: 'Telefonnummer', + description: 'Deutsche Telefonnummern (Mobil/Festnetz)', + example: '+49 170 12345678', + prefix: 'PHONE', + sensitiveLevel: 'medium', + }, + birthdate: { + type: 'birthdate', + label: 'Geburtsdatum', + description: 'Geburtsdatum in verschiedenen Formaten', + example: '01.01.1990', + prefix: 'BIRTHDATE', + sensitiveLevel: 'high', + }, + account_number: { + type: 'account_number', + label: 'Kontonummer', + description: 'Bankkontonummer (nicht IBAN)', + example: '1234567890', + prefix: 'ACCOUNT', + sensitiveLevel: 'critical', + }, + iban: { + type: 'iban', + label: 'IBAN', + description: 'Internationale Bankkontonummer', + example: 'DE89 3704 0044 0532 0130 00', + prefix: 'IBAN', + sensitiveLevel: 'critical', + }, + employee_id: { + type: 'employee_id', + label: 'Personalnummer', + description: 'Mitarbeiter- oder Personalnummer', + example: 'EMP-12345', + prefix: 'EMPID', + sensitiveLevel: 'medium', + }, + credit_card: { + type: 'credit_card', + label: 'Kreditkarte', + description: 'Kreditkartennummer', + example: '4111 1111 1111 1111', + prefix: 'CC', + sensitiveLevel: 'critical', + }, +}; + +// Regex-Patterns für Vorab-Validierung (zusätzlich zum LLM) +export const PII_PATTERNS: Record = { + email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, + phone: /(?:\+49|0)[\s\-/]?[\d\s\-/]{7,15}\b/g, + iban: /\b[A-Z]{2}\d{2}[\s]?[\d]{4}[\s]?[\d]{4}[\s]?[\d]{4}[\s]?[\d]{4}[\s]?[\d]{0,4}\b/g, + credit_card: /\b(?:\d{4}[\s-]?){3,4}\d{1,4}\b/g, + birthdate: /\b(?:0?[1-9]|[12][0-9]|3[01])[\.\/\-](?:0?[1-9]|1[0-2])[\.\/\-](?:19|20)\d{2}\b/g, + account_number: /\b\d{8,12}\b/g, + employee_id: /\b(?:EMP|PN|MA)[\-\s]?\d{4,10}\b/gi, + name_person: /\b[A-ZÄÖÜ][a-zäöüß]+\s+[A-ZÄÖÜ][a-zäöüß]+\b/g, + name_company: /\b[A-ZÄÖÜ][a-zäöüß]+(?:\s+(?:GmbH|AG|KG|OHG|e\.?V\.?|UG|Ltd|Inc|Corp))\b/gi, + address: /\b[A-ZÄÖÜ][a-zäöüß]+(?:straße|str|weg|platz|allee|gasse)\s+\d+[\s,]*\d{5}\s+[A-ZÄÖÜ][a-zäöüß]+\b/gi, +}; diff --git a/anonymizer/src/prompts/pii-detection.ts b/anonymizer/src/prompts/pii-detection.ts new file mode 100644 index 0000000..b1612b7 --- /dev/null +++ b/anonymizer/src/prompts/pii-detection.ts @@ -0,0 +1,172 @@ +/** + * Prompt-Templates für PII-Erkennung + * Optimiert für lokale LLMs (Ollama) + */ + +export interface PromptConfig { + model: string; + temperature: number; + maxTokens: number; + systemPrompt: string; + userPromptTemplate: string; +} + +// System-Prompt: Definiert die Rolle und Regeln +export const SYSTEM_PROMPT = `Du bist ein spezialisierter Datenschutz-Assistent für PII-Erkennung (Personally Identifiable Information). +Deine Aufgabe ist es, persönliche Daten in Texten zu identifizieren und zu anonymisieren. + +REGELN: +1. Identifiziere ALLE persönlichen Informationen im Text +2. Ersetze jede PII durch einen neutralen Platzhalter im Format [TYP_NUMMER] +3. Antworte NUR mit gültigem JSON - kein Markdown, keine Erklärungen +4. Sei gründlich: Es ist besser zu viel zu erkennen als zu wenig +5. Bei Namen: Unterscheide zwischen Personen- und Firmennamen +6. Bei Adressen: Erkenne komplette Adressen (Straße + Ort) +7. Bei Telefonnummern: Erkenne deutsche Formate (+49, 0...) +8. Bei Datumsangaben: Nur Geburtsdaten (nicht Termine) + +PII-TYPEN: +- name_person: Vor- und Nachname einer Person (z.B. "Max Mustermann") +- name_company: Firmenname (z.B. "Musterfirma GmbH") +- address: Vollständige Adresse (z.B. "Musterstraße 1, 12345 Berlin") +- email: E-Mail-Adresse (z.B. "max@beispiel.de") +- phone: Telefonnummer (z.B. "+49 170 12345678") +- birthdate: Geburtsdatum (z.B. "15.03.1985") +- account_number: Kontonummer (z.B. "1234567890") +- iban: IBAN (z.B. "DE89 3704 0044 0532 0130 00") +- employee_id: Personalnummer (z.B. "EMP-12345") +- credit_card: Kreditkartennummer (z.B. "4111 1111 1111 1111") + +WICHTIG: Der anonymisierte Text muss natürlich lesbar bleiben, nur die sensitiven Daten werden ersetzt.`; + +// User-Prompt Template mit Platzhalter für den zu analysierenden Text +export const USER_PROMPT_TEMPLATE = `Analysiere den folgenden Text und identifiziere ALLE persönlichen Informationen (PII). + +Für jede gefundene PII gib zurück: +- type: Der PII-Typ (siehe System-Regeln) +- original: Der originale Wert im Text +- replacement: Ein eindeutiger Platzhalter [TYP_INDEX] + +ANTWORTE NUR mit diesem JSON-Format: +{ + "pii_found": [ + { + "type": "name_person", + "original": "Max Mustermann", + "replacement": "[NAME_1]" + } + ], + "anonymized_text": "Hallo [NAME_1], ..." +} + +Zu analysierender Text: +--- +{{TEXT}} +---`; + +// Erweiterte Version mit Beispielen für bessere Few-Shot Performance +export const USER_PROMPT_TEMPLATE_FEW_SHOT = `Analysiere den folgenden Text und identifiziere ALLE persönlichen Informationen (PII). + +BEISPIELE: + +Eingabe: "Herr Klaus Müller von der Firma Schmidt & Co GmbH wohnt in der Hauptstraße 42, 10115 Berlin. Erreichbar unter klaus.mueller@email.de oder 030-12345678." +Ausgabe: +{ + "pii_found": [ + {"type": "name_person", "original": "Klaus Müller", "replacement": "[NAME_1]"}, + {"type": "name_company", "original": "Schmidt & Co GmbH", "replacement": "[COMPANY_1]"}, + {"type": "address", "original": "Hauptstraße 42, 10115 Berlin", "replacement": "[ADDRESS_1]"}, + {"type": "email", "original": "klaus.mueller@email.de", "replacement": "[EMAIL_1]"}, + {"type": "phone", "original": "030-12345678", "replacement": "[PHONE_1]"} + ], + "anonymized_text": "Herr [NAME_1] von der Firma [COMPANY_1] wohnt in der [ADDRESS_1]. Erreichbar unter [EMAIL_1] oder [PHONE_1]." +} + +Eingabe: "Überweisung an Max Mustermann, IBAN: DE89 3704 0044 0532 0130 00, Geburtsdatum: 15.03.1985" +Ausgabe: +{ + "pii_found": [ + {"type": "name_person", "original": "Max Mustermann", "replacement": "[NAME_1]"}, + {"type": "iban", "original": "DE89 3704 0044 0532 0130 00", "replacement": "[IBAN_1]"}, + {"type": "birthdate", "original": "15.03.1985", "replacement": "[BIRTHDATE_1]"} + ], + "anonymized_text": "Überweisung an [NAME_1], IBAN: [IBAN_1], Geburtsdatum: [BIRTHDATE_1]" +} + +ANWENDUNG: + +Eingabe: {{TEXT}} +Ausgabe:`; + +// Konfiguration für verschiedene Modelle +export const MODEL_CONFIGS: Record = { + 'llama3.2': { + model: 'llama3.2', + temperature: 0.1, + maxTokens: 4096, + systemPrompt: SYSTEM_PROMPT, + userPromptTemplate: USER_PROMPT_TEMPLATE, + }, + 'mistral': { + model: 'mistral', + temperature: 0.1, + maxTokens: 4096, + systemPrompt: SYSTEM_PROMPT, + userPromptTemplate: USER_PROMPT_TEMPLATE, + }, + 'qwen2.5': { + model: 'qwen2.5', + temperature: 0.1, + maxTokens: 4096, + systemPrompt: SYSTEM_PROMPT, + userPromptTemplate: USER_PROMPT_TEMPLATE_FEW_SHOT, + }, + 'gemma2': { + model: 'gemma2', + temperature: 0.1, + maxTokens: 4096, + systemPrompt: SYSTEM_PROMPT, + userPromptTemplate: USER_PROMPT_TEMPLATE, + }, + 'phi4': { + model: 'phi4', + temperature: 0.1, + maxTokens: 4096, + systemPrompt: SYSTEM_PROMPT, + userPromptTemplate: USER_PROMPT_TEMPLATE_FEW_SHOT, + }, + 'default': { + model: 'llama3.2', + temperature: 0.1, + maxTokens: 4096, + systemPrompt: SYSTEM_PROMPT, + userPromptTemplate: USER_PROMPT_TEMPLATE, + }, +}; + +// Hilfsfunktion zum Generieren des Prompts +export function generatePrompt( + text: string, + modelName: string = 'default' +): { system: string; user: string; config: PromptConfig } { + const config = MODEL_CONFIGS[modelName] || MODEL_CONFIGS.default; + const userPrompt = config.userPromptTemplate.replace('{{TEXT}}', text); + + return { + system: config.systemPrompt, + user: userPrompt, + config, + }; +} + +// Optimierter Prompt für sehr kurze Texte (schnellere Verarbeitung) +export const SHORT_TEXT_SYSTEM_PROMPT = `Du bist ein PII-Erkennungs-Assistent. Erkenne alle persönlichen Daten. +Antworte nur mit JSON: {"pii_found":[],"anonymized_text":""} +Typen: name_person,name_company,address,email,phone,birthdate,account_number,iban,employee_id,credit_card`; + +export function generateShortPrompt(text: string): { system: string; user: string } { + return { + system: SHORT_TEXT_SYSTEM_PROMPT, + user: `Text: "${text}"\nJSON:`, + }; +} diff --git a/anonymizer/src/reverser.ts b/anonymizer/src/reverser.ts new file mode 100644 index 0000000..8e33a08 --- /dev/null +++ b/anonymizer/src/reverser.ts @@ -0,0 +1,261 @@ +/** + * Re-Identifizierung (De-Anonymisierung) + * Stellt anonymisierte Texte wieder her + */ + +import { PiiInstance, ReverseMapping, PII_TYPE_METADATA, PiiType } from './pii-types.js'; +import { extractTypeFromPlaceholder, extractIndexFromPlaceholder } from './utils/text.js'; + +export interface ReidentifyResult { + success: boolean; + reidentifiedText: string; + replacementsMade: number; + errors: string[]; +} + +export interface ReidentifyOptions { + strictMode: boolean; // Bei true: Fehler wenn Platzhalter nicht gefunden + partialMatch: boolean; // Bei true: Auch teilweise Übereinstimmungen + caseSensitive: boolean; // Bei false: Groß-/Kleinschreibung ignorieren +} + +export const DEFAULT_REIDENTIFY_OPTIONS: ReidentifyOptions = { + strictMode: false, + partialMatch: false, + caseSensitive: true, +}; + +/** + * Re-Identifiziert einen anonymisierten Text + * Ersetzt Platzhalter durch Originalwerte + */ +export function reidentify( + anonymizedText: string, + mapping: ReverseMapping, + options: Partial = {} +): ReidentifyResult { + const opts = { ...DEFAULT_REIDENTIFY_OPTIONS, ...options }; + const errors: string[] = []; + let reidentifiedText = anonymizedText; + let replacementsMade = 0; + + // Sortiere Platzhalter nach Länge (absteigend), damit längere zuerst ersetzt werden + const placeholders = Object.keys(mapping).sort((a, b) => b.length - a.length); + + for (const placeholder of placeholders) { + const pii = mapping[placeholder]; + + if (!pii || !pii.original) { + errors.push(`Missing original value for placeholder: ${placeholder}`); + if (opts.strictMode) { + return { + success: false, + reidentifiedText: anonymizedText, + replacementsMade, + errors, + }; + } + continue; + } + + // Ersetze Platzhalter durch Original + const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp( + escapedPlaceholder, + opts.caseSensitive ? 'g' : 'gi' + ); + + const matches = reidentifiedText.match(regex); + if (matches) { + reidentifiedText = reidentifiedText.replace(regex, pii.original); + replacementsMade += matches.length; + } else if (opts.strictMode) { + errors.push(`Placeholder not found in text: ${placeholder}`); + } + } + + return { + success: errors.length === 0 || !opts.strictMode, + reidentifiedText, + replacementsMade, + errors, + }; +} + +/** + * Inkrementelle Re-Identifizierung + * Fügt neue Ersetzungen zu bestehendem Text hinzu + */ +export function reidentifyIncremental( + currentText: string, + additionalMapping: ReverseMapping, + options?: Partial +): ReidentifyResult { + return reidentify(currentText, additionalMapping, options); +} + +/** + * Erstellt eine serialisierbare Version des Mappings + * Für Speicherung oder Übertragung + */ +export function serializeMapping(mapping: ReverseMapping): string { + // Entferne nicht-serialisierbare Felder + const serializable: Record = {}; + + for (const [placeholder, pii] of Object.entries(mapping)) { + serializable[placeholder] = { + type: pii.type, + original: pii.original, + replacement: pii.replacement, + }; + } + + return JSON.stringify(serializable, null, 2); +} + +/** + * Deserialisiert ein Mapping aus einem String + */ +export function deserializeMapping(serialized: string): ReverseMapping { + const parsed = JSON.parse(serialized) as Record; + const mapping: ReverseMapping = {}; + + for (const [placeholder, data] of Object.entries(parsed)) { + mapping[placeholder] = { + type: data.type as PiiType, + original: data.original, + replacement: data.replacement, + }; + } + + return mapping; +} + +/** + * Validiert ein Mapping + * Prüft auf Konsistenz und Vollständigkeit + */ +export function validateMapping(mapping: ReverseMapping): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + for (const [placeholder, pii] of Object.entries(mapping)) { + // Prüfe ob Platzhalter-Format korrekt + if (!placeholder.match(/^\[[A-Z_]+_\d+\]$/)) { + errors.push(`Invalid placeholder format: ${placeholder}`); + } + + // Prüfe ob PII-Typ bekannt + if (!PII_TYPE_METADATA[pii.type]) { + errors.push(`Unknown PII type "${pii.type}" for placeholder ${placeholder}`); + } + + // Prüfe ob Original-Wert vorhanden + if (!pii.original || pii.original.trim() === '') { + errors.push(`Missing or empty original value for placeholder ${placeholder}`); + } + + // Prüfe ob Platzhalter mit PII.replacement übereinstimmt + if (pii.replacement !== placeholder) { + errors.push(`Mismatch: placeholder "${placeholder}" vs replacement "${pii.replacement}"`); + } + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Mergt mehrere Mappings zu einem + * Bei Konflikten: spätere Mappings überschreiben frühere + */ +export function mergeMappings(...mappings: ReverseMapping[]): ReverseMapping { + const merged: ReverseMapping = {}; + + for (const mapping of mappings) { + for (const [placeholder, pii] of Object.entries(mapping)) { + merged[placeholder] = pii; + } + } + + return merged; +} + +/** + * Extrahiert Platzhalter aus einem anonymisierten Text + */ +export function extractPlaceholders(text: string): string[] { + const matches = text.match(/\[[A-Z_]+_\d+\]/g); + return matches ? [...new Set(matches)] : []; +} + +/** + * Erstellt ein partielles Mapping basierend auf gefundenen Platzhaltern + * Nützlich wenn man nur bestimmte PII-Typen re-identifizieren möchte + */ +export function filterMappingByTypes( + mapping: ReverseMapping, + types: PiiType[] +): ReverseMapping { + const filtered: ReverseMapping = {}; + + for (const [placeholder, pii] of Object.entries(mapping)) { + if (types.includes(pii.type)) { + filtered[placeholder] = pii; + } + } + + return filtered; +} + +/** + * Erstellt ein partielles Mapping basierend auf Placeholder-Präfix + */ +export function filterMappingByPlaceholder( + mapping: ReverseMapping, + prefix: string +): ReverseMapping { + const filtered: ReverseMapping = {}; + + for (const [placeholder, pii] of Object.entries(mapping)) { + if (placeholder.startsWith(`[${prefix}_`)) { + filtered[placeholder] = pii; + } + } + + return filtered; +} + +/** + * Berechnet Statistiken über ein Mapping + */ +export function getMappingStats(mapping: ReverseMapping): { + totalPlaceholders: number; + byType: Record; + sensitiveLevel: 'low' | 'medium' | 'high' | 'critical'; +} { + const byType: Record = {}; + let criticalCount = 0; + let highCount = 0; + + for (const pii of Object.values(mapping)) { + byType[pii.type] = (byType[pii.type] || 0) + 1; + + if (pii.type === 'credit_card' || pii.type === 'iban') { + criticalCount++; + } else if (['name_person', 'address', 'birthdate', 'email'].includes(pii.type)) { + highCount++; + } + } + + const totalPlaceholders = Object.keys(mapping).length; + + let sensitiveLevel: 'low' | 'medium' | 'high' | 'critical' = 'low'; + if (criticalCount > 0) sensitiveLevel = 'critical'; + else if (highCount >= 3 || totalPlaceholders >= 5) sensitiveLevel = 'high'; + else if (highCount > 0 || totalPlaceholders >= 2) sensitiveLevel = 'medium'; + + return { + totalPlaceholders, + byType, + sensitiveLevel, + }; +} diff --git a/anonymizer/src/test/test-cases.ts b/anonymizer/src/test/test-cases.ts new file mode 100644 index 0000000..f3c469a --- /dev/null +++ b/anonymizer/src/test/test-cases.ts @@ -0,0 +1,370 @@ +/** + * Testfälle für das PII-Anonymisierungs-System + * Umfassende Testabdeckung für verschiedene Szenarien + */ + +import { PiiDetectionResult, ReverseMapping } from '../pii-types.js'; + +export interface TestCase { + name: string; + description: string; + input: string; + expectedPii: Array<{ + type: string; + original: string; + }>; + shouldAnonymize: boolean; + category: 'basic' | 'advanced' | 'edge' | 'realworld'; +} + +export interface TestResult { + testCase: TestCase; + success: boolean; + anonymizedText?: string; + detectedPii?: PiiDetectionResult['piiFound']; + reidentifiedText?: string; + errors: string[]; +} + +// Basis-Testfälle +export const BASIC_TEST_CASES: TestCase[] = [ + { + name: 'einfache_email', + description: 'Einfache E-Mail-Adresse', + input: 'Kontaktieren Sie mich unter max.mustermann@beispiel.de', + expectedPii: [ + { type: 'email', original: 'max.mustermann@beispiel.de' }, + ], + shouldAnonymize: true, + category: 'basic', + }, + { + name: 'deutsche_telefonnummer', + description: 'Deutsche Telefonnummer mit Vorwahl', + input: 'Rufen Sie mich an: 030-12345678 oder +49 170 12345678', + expectedPii: [ + { type: 'phone', original: '030-12345678' }, + { type: 'phone', original: '+49 170 12345678' }, + ], + shouldAnonymize: true, + category: 'basic', + }, + { + name: 'personenname', + description: 'Vollständiger Personenname', + input: 'Herr Dr. Klaus Müller wird Sie betreuen.', + expectedPii: [ + { type: 'name_person', original: 'Klaus Müller' }, + ], + shouldAnonymize: true, + category: 'basic', + }, + { + name: 'vollstaendige_adresse', + description: 'Vollständige deutsche Adresse', + input: 'Meine Adresse lautet: Musterstraße 42, 10115 Berlin', + expectedPii: [ + { type: 'address', original: 'Musterstraße 42, 10115 Berlin' }, + ], + shouldAnonymize: true, + category: 'basic', + }, + { + name: 'iban', + description: 'Deutsche IBAN', + input: 'Bitte überweisen Sie auf DE89 3704 0044 0532 0130 00', + expectedPii: [ + { type: 'iban', original: 'DE89 3704 0044 0532 0130 00' }, + ], + shouldAnonymize: true, + category: 'basic', + }, + { + name: 'geburtsdatum', + description: 'Geburtsdatum verschiedener Formate', + input: 'Geboren am 15.03.1985, zuvor am 15/03/1985', + expectedPii: [ + { type: 'birthdate', original: '15.03.1985' }, + ], + shouldAnonymize: true, + category: 'basic', + }, +]; + +// Erweiterte Testfälle +export const ADVANCED_TEST_CASES: TestCase[] = [ + { + name: 'kombination_mehrere_pii', + description: 'Mehrere PII-Typen in einem Text', + input: 'Hallo, ich bin Anna Schmidt von der Firma Tech Solutions GmbH. \ +Sie erreichen mich unter anna.schmidt@tech-solutions.de oder +49 89 12345678. \ +Meine Adresse: Hauptstraße 1, 80331 München. IBAN: DE12 3456 7890 1234 5678 90', + expectedPii: [ + { type: 'name_person', original: 'Anna Schmidt' }, + { type: 'name_company', original: 'Tech Solutions GmbH' }, + { type: 'email', original: 'anna.schmidt@tech-solutions.de' }, + { type: 'phone', original: '+49 89 12345678' }, + { type: 'address', original: 'Hauptstraße 1, 80331 München' }, + { type: 'iban', original: 'DE12 3456 7890 1234 5678 90' }, + ], + shouldAnonymize: true, + category: 'advanced', + }, + { + name: 'kreditkarte', + description: 'Kreditkartennummer', + input: 'Meine Karte: 4111 1111 1111 1111 läuft ab 12/25', + expectedPii: [ + { type: 'credit_card', original: '4111 1111 1111 1111' }, + ], + shouldAnonymize: true, + category: 'advanced', + }, + { + name: 'personalnummer', + description: 'Personalnummer verschiedener Formate', + input: 'Meine Personalnummer ist EMP-12345 oder MA-98765', + expectedPii: [ + { type: 'employee_id', original: 'EMP-12345' }, + ], + shouldAnonymize: true, + category: 'advanced', + }, + { + name: 'kontonummer', + description: 'Alte Kontonummer (nicht IBAN)', + input: 'Bitte überweisen Sie auf Konto 1234567890, BLZ 70090100', + expectedPii: [ + { type: 'account_number', original: '1234567890' }, + ], + shouldAnonymize: true, + category: 'advanced', + }, +]; + +// Edge Cases +export const EDGE_TEST_CASES: TestCase[] = [ + { + name: 'keine_pii', + description: 'Text ohne erkennbare PII', + input: 'Das Wetter ist heute wunderschön und ich plane einen Spaziergang.', + expectedPii: [], + shouldAnonymize: false, + category: 'edge', + }, + { + name: 'leerer_text', + description: 'Leerer Eingabetext', + input: '', + expectedPii: [], + shouldAnonymize: false, + category: 'edge', + }, + { + name: 'nur_whitespace', + description: 'Nur Leerzeichen', + input: ' \n\t ', + expectedPii: [], + shouldAnonymize: false, + category: 'edge', + }, + { + name: 'sehr_langer_text', + description: 'Sehr langer Text mit verstreuten PII', + input: 'Lorem ipsum '.repeat(100) + ' max.mustermann@beispiel.de ' + 'dolor sit '.repeat(100), + expectedPii: [ + { type: 'email', original: 'max.mustermann@beispiel.de' }, + ], + shouldAnonymize: true, + category: 'edge', + }, + { + name: 'gleiche_pii_mehrfach', + description: 'Selbe E-Mail mehrfach im Text', + input: 'Schreiben Sie an kontakt@firma.de oder kontakt@firma.de', + expectedPii: [ + { type: 'email', original: 'kontakt@firma.de' }, + ], + shouldAnonymize: true, + category: 'edge', + }, + { + name: 'aehnliche_worte', + description: 'Wörter die wie PII aussehen aber keine sind', + input: 'Das Passwort lautet test@123 und die Adresse ist localhost', + expectedPii: [], + shouldAnonymize: false, + category: 'edge', + }, +]; + +// Real-World Szenarien +export const REALWORLD_TEST_CASES: TestCase[] = [ + { + name: 'support_ticket', + description: 'Typischer Support-Chat mit Kundendaten', + input: `Kunde: Hallo, ich habe ein Problem mit meinem Konto. +Support: Guten Tag, wie kann ich helfen? +Kunde: Mein Name ist Sabine Weber, ich wohne in Berlin und meine Mail ist s.weber@web.de +Support: Danke Frau Weber, ich schaue mir das an.`, + expectedPii: [ + { type: 'name_person', original: 'Sabine Weber' }, + { type: 'email', original: 's.weber@web.de' }, + ], + shouldAnonymize: true, + category: 'realworld', + }, + { + name: 'rechnung_kontext', + description: 'Rechnungsinformationen', + input: `Rechnung Nr. 2024-001 an +Herrn Peter Schmidt +Musterweg 15 +20095 Hamburg + +Betrag: 1.234,56 EUR +IBAN: DE44 2001 0020 0123 4567 89`, + expectedPii: [ + { type: 'name_person', original: 'Peter Schmidt' }, + { type: 'address', original: 'Musterweg 15, 20095 Hamburg' }, + { type: 'iban', original: 'DE44 2001 0020 0123 4567 89' }, + ], + shouldAnonymize: true, + category: 'realworld', + }, + { + name: 'terminplanung', + description: 'Terminplanung mit Kontaktdaten', + input: `Hallo Frau Dr. Angela Müller, + +wir haben Ihren Termin am 15.03.2024 um 14:30 Uhr bestätigt. + +Bei Fragen erreichen Sie uns unter: +- E-Mail: praxis@mueller-med.de +- Telefon: 089 / 12345678 + +Mit freundlichen Grüßen +Praxis Dr. Müller`, + expectedPii: [ + { type: 'name_person', original: 'Angela Müller' }, + { type: 'email', original: 'praxis@mueller-med.de' }, + { type: 'phone', original: '089 / 12345678' }, + ], + shouldAnonymize: true, + category: 'realworld', + }, +]; + +// Alle Testfälle zusammen +export const ALL_TEST_CASES: TestCase[] = [ + ...BASIC_TEST_CASES, + ...ADVANCED_TEST_CASES, + ...EDGE_TEST_CASES, + ...REALWORLD_TEST_CASES, +]; + +/** + * Validiert ein Anonymisierungsergebnis gegen erwartete Werte + */ +export function validateResult( + result: PiiDetectionResult, + testCase: TestCase +): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // Prüfe ob anonymisiert wurde wenn erwartet + if (testCase.shouldAnonymize && result.anonymizedText === testCase.input) { + errors.push('Text wurde nicht anonymisiert'); + } + + // Prüfe ob alle erwarteten PII gefunden wurden + for (const expected of testCase.expectedPii) { + const found = result.piiFound.some( + p => p.type === expected.type && p.original === expected.original + ); + if (!found) { + errors.push(`Erwartete PII nicht gefunden: ${expected.type} = "${expected.original}"`); + } + } + + // Prüfe ob zu viele PII gefunden wurden (False Positives) + if (result.piiFound.length > testCase.expectedPii.length) { + const extra = result.piiFound.filter( + p => !testCase.expectedPii.some(e => e.original === p.original) + ); + errors.push(`Unerwartete PII gefunden: ${extra.map(p => `${p.type}="${p.original}"`).join(', ')}`); + } + + // Prüfe Re-Identifizierung + const reidentified = result.anonymizedText; + for (const pii of result.piiFound) { + if (reidentified.includes(pii.original)) { + errors.push(`Originalwert "${pii.original}" noch im anonymisierten Text enthalten`); + } + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Führt einen Testfall aus + */ +export async function runTestCase( + testCase: TestCase, + anonymizeFn: (text: string) => Promise, + reidentifyFn: (text: string, mapping: ReverseMapping) => string +): Promise { + const errors: string[] = []; + + try { + // Anonymisiere + const result = await anonymizeFn(testCase.input); + + // Validiere + const validation = validateResult(result, testCase); + errors.push(...validation.errors); + + // Re-Identifiziere + const mapping: ReverseMapping = {}; + for (const pii of result.piiFound) { + mapping[pii.replacement] = pii; + } + const reidentifiedText = reidentifyFn(result.anonymizedText, mapping); + + // Prüfe ob Re-Identifizierung erfolgreich + if (reidentifiedText !== testCase.input) { + errors.push('Re-Identifizierung ergab nicht den Originaltext'); + } + + return { + testCase, + success: errors.length === 0, + anonymizedText: result.anonymizedText, + detectedPii: result.piiFound, + reidentifiedText, + errors, + }; + + } catch (error) { + errors.push(`Exception: ${error instanceof Error ? error.message : String(error)}`); + return { + testCase, + success: false, + errors, + }; + } +} + +// Beispiel-Mapping für manuelle Tests +export const SAMPLE_MAPPING: ReverseMapping = { + '[NAME_1]': { type: 'name_person', original: 'Max Mustermann', replacement: '[NAME_1]' }, + '[EMAIL_1]': { type: 'email', original: 'max@beispiel.de', replacement: '[EMAIL_1]' }, + '[PHONE_1]': { type: 'phone', original: '030-12345678', replacement: '[PHONE_1]' }, + '[ADDRESS_1]': { type: 'address', original: 'Musterstraße 1, 12345 Berlin', replacement: '[ADDRESS_1]' }, + '[IBAN_1]': { type: 'iban', original: 'DE89 3704 0044 0532 0130 00', replacement: '[IBAN_1]' }, + '[COMPANY_1]': { type: 'name_company', original: 'Musterfirma GmbH', replacement: '[COMPANY_1]' }, +}; + +export const SAMPLE_ANONYMIZED_TEXT = + 'Hallo [NAME_1], kontaktiere mich unter [EMAIL_1] oder [PHONE_1]. \ +Ich wohne in [ADDRESS_1]. Überweise an [IBAN_1]. Arbeite bei [COMPANY_1].'; diff --git a/anonymizer/src/utils/text.ts b/anonymizer/src/utils/text.ts new file mode 100644 index 0000000..a4c7c59 --- /dev/null +++ b/anonymizer/src/utils/text.ts @@ -0,0 +1,158 @@ +/** + * Hilfsfunktionen für Textverarbeitung und PII-Handling + */ + +import { PiiInstance, PiiType, PII_TYPE_METADATA } from '../pii-types.js'; + +/** + * Generiert einen eindeutigen Platzhalter für einen PII-Typ + */ +export function generatePlaceholder(type: PiiType, index: number): string { + const metadata = PII_TYPE_METADATA[type]; + return `[${metadata.prefix}_${index}]`; +} + +/** + * Extrahiert den Typ aus einem Platzhalter + * z.B. "[NAME_1]" -> "name_person" + */ +export function extractTypeFromPlaceholder(placeholder: string): PiiType | null { + const match = placeholder.match(/\[([A-Z_]+)_\d+\]/); + if (!match) return null; + + const prefix = match[1]; + + // Reverse lookup vom Prefix zum Typ + for (const [type, metadata] of Object.entries(PII_TYPE_METADATA)) { + if (metadata.prefix === prefix) { + return type as PiiType; + } + } + + return null; +} + +/** + * Extrahiert den Index aus einem Platzhalter + * z.B. "[NAME_1]" -> 1 + */ +export function extractIndexFromPlaceholder(placeholder: string): number { + const match = placeholder.match(/\[[A-Z_]+_(\d+)\]/); + return match ? parseInt(match[1], 10) : 0; +} + +/** + * Ersetzt alle Vorkommen eines Strings in einem Text + * Beachtet: Längere Strings zuerst ersetzen, um Teil-Überschneidungen zu vermeiden + */ +export function replaceInText( + text: string, + replacements: Array<{ original: string; replacement: string }> +): string { + // Sortiere nach Länge (absteigend), damit längere Strings zuerst ersetzt werden + const sorted = [...replacements].sort((a, b) => b.original.length - a.original.length); + + let result = text; + for (const { original, replacement } of sorted) { + // Escape spezielle Regex-Zeichen im Original + const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Globale Ersetzung (case-sensitive für Präzision) + result = result.replace(new RegExp(escaped, 'g'), replacement); + } + + return result; +} + +/** + * Bereinigt JSON-String von Markdown-Code-Blocks und Whitespace + */ +export function cleanJsonResponse(response: string): string { + return response + .replace(/```json\n?/g, '') + .replace(/```\n?/g, '') + .replace(/^\s+|\s+$/g, ''); +} + +/** + * Validiert und repariert ein PII-Ergebnis + * Stellt sicher, dass alle Felder vorhanden sind + */ +export function validatePiiResult(result: unknown): result is { pii_found: unknown[]; anonymized_text: string } { + if (typeof result !== 'object' || result === null) return false; + const r = result as Record; + return Array.isArray(r.pii_found) && typeof r.anonymized_text === 'string'; +} + +/** + * Berechnet die Sensitivitätsstufe eines Textes + * Basierend auf der Anzahl und Art der erkannten PII + */ +export function calculateSensitivityLevel(piiList: PiiInstance[]): 'low' | 'medium' | 'high' | 'critical' { + if (piiList.length === 0) return 'low'; + + const criticalCount = piiList.filter(p => p.type === 'credit_card' || p.type === 'iban').length; + const highCount = piiList.filter(p => + p.type === 'name_person' || + p.type === 'address' || + p.type === 'birthdate' || + p.type === 'email' + ).length; + + if (criticalCount > 0) return 'critical'; + if (highCount >= 3 || piiList.length >= 5) return 'high'; + if (highCount > 0 || piiList.length >= 2) return 'medium'; + + return 'low'; +} + +/** + * Maskiert einen PII-Wert für Logging/Anzeige + * z.B. "max.mustermann@email.de" -> "m***@e***.de" + */ +export function maskPiiForLog(value: string, type: PiiType): string { + if (value.length <= 4) return '****'; + + if (type === 'email') { + const [local, domain] = value.split('@'); + if (!domain) return value.substring(0, 2) + '***'; + const [domainName, tld] = domain.split('.'); + return `${local.substring(0, 1)}***@${domainName?.substring(0, 1) ?? ''}***.${tld ?? ''}`; + } + + if (type === 'phone' || type === 'credit_card' || type === 'iban') { + return value.substring(0, 4) + ' **** **** ' + value.substring(value.length - 4); + } + + // Default: Erste 2 und letzte 2 Zeichen + return value.substring(0, 2) + '***' + value.substring(value.length - 2); +} + +/** + * Zählt die Vorkommen eines Substrings (case-sensitive) + */ +export function countOccurrences(text: string, search: string): number { + if (!search) return 0; + const matches = text.match(new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')); + return matches?.length ?? 0; +} + +/** + * Prüft, ob ein Text potenziell PII enthält (schneller Vorab-Check) + */ +export function mightContainPii(text: string): boolean { + // Schnelle Heuristik: Enthält E-Mail, viele Zahlen oder bestimmte Schlüsselwörter? + const hasEmail = text.includes('@'); + const hasPhone = /\b(?:\+49|0)[\d\s\-/]{7,}\b/.test(text); + const hasDate = /\b\d{1,2}[\.\/\-]\d{1,2}[\.\/\-]\d{2,4}\b/.test(text); + const hasIban = /\b[A-Z]{2}\d{2}\b/.test(text); + + return hasEmail || hasPhone || hasDate || hasIban; +} + +/** + * Truncates text for display purposes + */ +export function truncateText(text: string, maxLength: number = 100): string { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength - 3) + '...'; +} diff --git a/anonymizer/tsconfig.json b/anonymizer/tsconfig.json new file mode 100644 index 0000000..9a59fb8 --- /dev/null +++ b/anonymizer/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "isolatedModules": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..28ea203 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,25 @@ +# Database +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=privacy_gateway +DB_USER=postgres +DB_PASSWORD=your_password + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6379 + +# Ollama (Local Anonymizer) +OLLAMA_HOST=localhost +OLLAMA_PORT=11434 +OLLAMA_TARGET_HOST=localhost +OLLAMA_TARGET_PORT=11434 + +# Models +ANONYMIZATION_MODEL=llama3.2 +CHAT_MODEL=llama3.2 + +# Server +PORT=3000 +NODE_ENV=development +LOG_LEVEL=info diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..4d9d3f9 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,36 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY tsconfig.json ./ +COPY src ./src + +RUN npm run build + +# Production stage +FROM node:20-alpine AS production + +WORKDIR /app + +RUN apk add --no-cache ca-certificates + +ENV NODE_ENV=production +ENV PORT=3000 + +COPY package*.json ./ +RUN npm ci --only=production && npm cache clean --force + +COPY --from=builder /app/dist ./dist + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 + +USER node + +CMD ["node", "dist/server.js"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..95ab7eb --- /dev/null +++ b/backend/README.md @@ -0,0 +1,74 @@ +# Privacy Gateway Backend + +Node.js/Express API mit Ollama-Proxy und Session-Management für den Privacy Gateway. + +## Features + +- **Session-Management**: Erstellen, Lesen, Löschen von Chat-Sessions +- **PII-Anonymisierung**: Automatische Erkennung und Maskierung sensibler Daten +- **Ollama-Proxy**: Transparente Weiterleitung zu externen KI-Modellen +- **Streaming**: SSE-basierte Antwort-Streams +- **Caching**: Redis-basierte Performance-Optimierung +- **PostgreSQL**: Persistente Datenspeicherung + +## Schnelleinstieg + +```bash +# 1. Umgebungsvariablen konfigurieren +cp .env.example .env +# Bearbeite .env mit deinen Daten + +# 2. Mit Docker Compose starten +docker-compose up -d + +# 3. Ollama-Modell herunterladen +docker-compose exec anonymizer ollama pull llama3.2 +``` + +## API-Endpunkte + +| Methode | Endpunkt | Beschreibung | +|---------|----------|--------------| +| GET | `/api/sessions` | Alle Sessions abrufen | +| POST | `/api/sessions` | Neue Session erstellen | +| GET | `/api/sessions/:id` | Session mit Messages | +| DELETE | `/api/sessions/:id` | Session löschen | +| POST | `/api/sessions/:id/chat` | Chat-Nachricht senden | +| GET | `/api/models` | Verfügbare Modelle | +| GET | `/health` | Health Check | +| GET | `/ready` | Readiness Check | + +## Entwicklung + +```bash +# Lokale Installation +npm install +npm run dev + +# Build +npm run build +npm start +``` + +## Architektur + +``` +Client → Express API → [Anonymizer (Ollama)] → Externe KI + ↓ + PostgreSQL + Redis +``` + +## PII-Typen + +- `PERSON`: Personennamen +- `EMAIL`: E-Mail-Adressen +- `PHONE`: Telefonnummern +- `ADDRESS`: Adressen +- `ORG`: Organisationen +- `ID`: Identifikationsnummern +- `DATE`: Persönliche Daten +- `FINANCIAL`: Bank-/Kreditkarten-Daten + +## Lizenz + +Proprietär - Täger IT & Gebäude-Systeme diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..1ab000c --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,98 @@ +version: '3.8' + +services: + backend: + build: + context: . + dockerfile: Dockerfile + container_name: privacy-gateway-backend + restart: unless-stopped + ports: + - "${PORT:-3000}:3000" + environment: + - NODE_ENV=${NODE_ENV:-production} + - PORT=3000 + - DB_HOST=postgres + - DB_PORT=5432 + - DB_NAME=${DB_NAME:-privacy_gateway} + - DB_USER=${DB_USER:-postgres} + - DB_PASSWORD=${DB_PASSWORD} + - REDIS_HOST=redis + - REDIS_PORT=6379 + - OLLAMA_HOST=${OLLAMA_HOST:-anonymizer} + - OLLAMA_PORT=${OLLAMA_PORT:-11434} + - OLLAMA_TARGET_HOST=${OLLAMA_TARGET_HOST:-ollama} + - OLLAMA_TARGET_PORT=${OLLAMA_TARGET_PORT:-11434} + - ANONYMIZATION_MODEL=${ANONYMIZATION_MODEL:-llama3.2} + - CHAT_MODEL=${CHAT_MODEL:-llama3.2} + - LOG_LEVEL=${LOG_LEVEL:-info} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + anonymizer: + condition: service_started + networks: + - privacy-gateway + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + postgres: + image: postgres:16-alpine + container_name: privacy-gateway-postgres + restart: unless-stopped + environment: + - POSTGRES_DB=${DB_NAME:-privacy_gateway} + - POSTGRES_USER=${DB_USER:-postgres} + - POSTGRES_PASSWORD=${DB_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - privacy-gateway + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: privacy-gateway-redis + restart: unless-stopped + volumes: + - redis_data:/data + networks: + - privacy-gateway + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + anonymizer: + image: ollama/ollama:latest + container_name: privacy-gateway-anonymizer + restart: unless-stopped + environment: + - OLLAMA_HOST=0.0.0.0 + - OLLAMA_PORT=11434 + volumes: + - anonymizer_models:/root/.ollama + networks: + - privacy-gateway + # Remove GPU access for broader compatibility + command: serve + +volumes: + postgres_data: + redis_data: + anonymizer_models: + +networks: + privacy-gateway: + driver: bridge diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..e2234f1 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,42 @@ +{ + "name": "privacy-gateway-backend", + "version": "1.0.0", + "description": "Privacy Gateway Backend - Ollama Proxy mit Anonymisierung", + "main": "dist/server.js", + "scripts": { + "build": "tsc", + "start": "node dist/server.js", + "dev": "tsx src/server.ts", + "watch": "tsx watch src/server.ts", + "lint": "eslint src --ext .ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "helmet": "^7.1.0", + "dotenv": "^16.3.1", + "pg": "^8.11.3", + "redis": "^4.6.12", + "uuid": "^9.0.1", + "express-validator": "^7.0.1", + "morgan": "^1.10.0", + "winston": "^3.11.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/cors": "^2.8.17", + "@types/node": "^20.10.5", + "@types/pg": "^8.10.9", + "@types/uuid": "^9.0.7", + "@types/morgan": "^1.9.9", + "typescript": "^5.3.3", + "tsx": "^4.7.0", + "eslint": "^8.56.0", + "@typescript-eslint/eslint-plugin": "^6.15.0", + "@typescript-eslint/parser": "^6.15.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts new file mode 100644 index 0000000..eec39a1 --- /dev/null +++ b/backend/src/db/index.ts @@ -0,0 +1,236 @@ +import { Pool, PoolConfig, QueryResult } from 'pg'; +import { Logger } from 'winston'; +import { createLogger } from '../utils/logger'; +import { Session, Message, PIIMapping } from '../types'; + +const logger: Logger = createLogger('Database'); + +const poolConfig: PoolConfig = { + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432', 10), + database: process.env.DB_NAME || 'privacy_gateway', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || '', + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, +}; + +const pool = new Pool(poolConfig); + +pool.on('connect', () => { + logger.info('New database connection established'); +}); + +pool.on('error', (err: Error) => { + logger.error('Unexpected database error', { error: err.message }); +}); + +export const query = async ( + text: string, + params?: unknown[] +): Promise> => { + const start = Date.now(); + try { + const result = await pool.query(text, params); + const duration = Date.now() - start; + logger.debug('Query executed', { duration, rows: result.rowCount }); + return result; + } catch (error) { + logger.error('Query failed', { error: (error as Error).message, text }); + throw error; + } +}; + +export const getPool = (): Pool => pool; + +export const closePool = async (): Promise => { + await pool.end(); + logger.info('Database pool closed'); +}; + +export const initDB = async (): Promise => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + await client.query(` + CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + model VARCHAR(100) NOT NULL DEFAULT 'llama3.2', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + metadata JSONB + ) + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL CHECK (role IN ('user', 'assistant', 'system')), + original_content TEXT NOT NULL, + anonymized_content TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ) + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS pii_mappings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + pii_type VARCHAR(50) NOT NULL, + original_value TEXT NOT NULL, + anonymized_value TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ) + `); + + await client.query(` + CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id) + `); + + await client.query(` + CREATE INDEX IF NOT EXISTS idx_pii_mappings_session_id ON pii_mappings(session_id) + `); + + await client.query(` + CREATE INDEX IF NOT EXISTS idx_pii_mappings_message_id ON pii_mappings(message_id) + `); + + await client.query('COMMIT'); + logger.info('Database initialized successfully'); + } catch (error) { + await client.query('ROLLBACK'); + logger.error('Database initialization failed', { error: (error as Error).message }); + throw error; + } finally { + client.release(); + } +}; + +export const SessionQueries = { + create: async (name: string, model: string, metadata?: Record): Promise => { + const result = await query( + 'INSERT INTO sessions (name, model, metadata) VALUES ($1, $2, $3) RETURNING *', + [name, model, metadata ? JSON.stringify(metadata) : null] + ); + return result.rows[0]; + }, + + findAll: async (): Promise => { + const result = await query( + 'SELECT * FROM sessions ORDER BY updated_at DESC' + ); + return result.rows; + }, + + findById: async (id: string): Promise => { + const result = await query( + 'SELECT * FROM sessions WHERE id = $1', + [id] + ); + return result.rows[0] || null; + }, + + update: async (id: string, updates: Partial): Promise => { + const setClauses: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + if (updates.name) { + setClauses.push(`name = $${paramIndex++}`); + values.push(updates.name); + } + if (updates.model) { + setClauses.push(`model = $${paramIndex++}`); + values.push(updates.model); + } + if (updates.metadata) { + setClauses.push(`metadata = $${paramIndex++}`); + values.push(JSON.stringify(updates.metadata)); + } + + setClauses.push(`updated_at = CURRENT_TIMESTAMP`); + values.push(id); + + const result = await query( + `UPDATE sessions SET ${setClauses.join(', ')} WHERE id = $${paramIndex} RETURNING *`, + values + ); + return result.rows[0] || null; + }, + + delete: async (id: string): Promise => { + const result = await query( + 'DELETE FROM sessions WHERE id = $1', + [id] + ); + return (result.rowCount || 0) > 0; + }, +}; + +export const MessageQueries = { + create: async ( + sessionId: string, + role: string, + originalContent: string, + anonymizedContent: string + ): Promise => { + const result = await query( + 'INSERT INTO messages (session_id, role, original_content, anonymized_content) VALUES ($1, $2, $3, $4) RETURNING *', + [sessionId, role, originalContent, anonymizedContent] + ); + return result.rows[0]; + }, + + findBySessionId: async (sessionId: string): Promise => { + const result = await query( + 'SELECT * FROM messages WHERE session_id = $1 ORDER BY created_at ASC', + [sessionId] + ); + return result.rows; + }, + + findById: async (id: string): Promise => { + const result = await query( + 'SELECT * FROM messages WHERE id = $1', + [id] + ); + return result.rows[0] || null; + }, +}; + +export const PIIMappingQueries = { + create: async ( + sessionId: string, + messageId: string, + piiType: string, + originalValue: string, + anonymizedValue: string + ): Promise => { + const result = await query( + 'INSERT INTO pii_mappings (session_id, message_id, pii_type, original_value, anonymized_value) VALUES ($1, $2, $3, $4, $5) RETURNING *', + [sessionId, messageId, piiType, originalValue, anonymizedValue] + ); + return result.rows[0]; + }, + + findBySessionId: async (sessionId: string): Promise => { + const result = await query( + 'SELECT * FROM pii_mappings WHERE session_id = $1', + [sessionId] + ); + return result.rows; + }, + + findByMessageId: async (messageId: string): Promise => { + const result = await query( + 'SELECT * FROM pii_mappings WHERE message_id = $1', + [messageId] + ); + return result.rows; + }, +}; diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts new file mode 100644 index 0000000..944ad96 --- /dev/null +++ b/backend/src/routes/chat.ts @@ -0,0 +1,194 @@ +import { Router, Request, Response } from 'express'; +import { body, param, validationResult } from 'express-validator'; +import { SessionQueries, MessageQueries, PIIMappingQueries } from '../db'; +import { anonymizeText, deanonymizeText } from '../services/anonymizer'; +import { chat, chatStream } from '../services/ollama'; +import { cacheDelete, cacheClearPattern } from '../services/redis'; +import { createLogger } from '../utils/logger'; +import { ChatRequest, OllamaMessage, OllamaChatResponse } from '../types'; + +const router = Router(); +const logger = createLogger('ChatRoute'); + +const CHAT_MODEL = process.env.CHAT_MODEL || 'llama3.2'; +const CACHE_PREFIX = 'session:'; + +const handleValidationErrors = (req: Request, res: Response, next: () => void) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + res.status(400).json({ + success: false, + errors: errors.array(), + }); + return; + } + next(); +}; + +router.post( + '/:id/chat', + [ + param('id').isUUID().withMessage('Invalid session ID'), + body('message').isString().trim().notEmpty().withMessage('Message is required'), + body('stream').optional().isBoolean(), + handleValidationErrors, + ], + async (req: Request, res: Response) => { + const { id } = req.params; + const { message, stream }: ChatRequest = req.body; + + try { + logger.debug('Processing chat request', { sessionId: id, stream }); + + const session = await SessionQueries.findById(id); + if (!session) { + res.status(404).json({ + success: false, + error: 'Session not found', + }); + return; + } + + const existingMessages = await MessageQueries.findBySessionId(id); + const history: OllamaMessage[] = existingMessages.map((msg) => ({ + role: msg.role as 'user' | 'assistant' | 'system', + content: msg.anonymized_content, + })); + + logger.debug('Anonymizing user message'); + const anonymizationResult = await anonymizeText(message); + + const userMessage = await MessageQueries.create( + id, + 'user', + message, + anonymizationResult.anonymizedText + ); + + for (const mapping of anonymizationResult.mappings) { + await PIIMappingQueries.create( + id, + userMessage.id, + mapping.pii_type, + mapping.original_value, + mapping.anonymized_value + ); + } + + if (stream) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + const messages: OllamaMessage[] = [ + ...history, + { role: 'user', content: anonymizationResult.anonymizedText }, + ]; + + let responseContent = ''; + let hasError = false; + + try { + await chatStream( + session.model || CHAT_MODEL, + messages, + (chunk: OllamaChatResponse) => { + if (chunk.message?.content) { + responseContent += chunk.message.content; + res.write( + `data: ${JSON.stringify({ + content: chunk.message.content, + done: chunk.done, + })}\n\n` + ); + } + if (chunk.done) { + res.write('data: [DONE]\n\n'); + } + }, + (error: Error) => { + logger.error('Stream error', { error: error.message }); + hasError = true; + res.write( + `data: ${JSON.stringify({ + error: error.message, + done: true, + })}\n\n` + ); + } + ); + + if (!hasError && responseContent) { + const allMappings = await PIIMappingQueries.findBySessionId(id); + const deanonymizedResponse = await deanonymizeText( + responseContent, + allMappings + ); + + await MessageQueries.create(id, 'assistant', deanonymizedResponse, responseContent); + + await cacheDelete(`${CACHE_PREFIX}${id}`); + await cacheClearPattern(`${CACHE_PREFIX}all`); + } + } catch (error) { + logger.error('Streaming chat failed', { error: (error as Error).message }); + res.write( + `data: ${JSON.stringify({ + error: 'Streaming failed', + done: true, + })}\n\n` + ); + } + + res.end(); + return; + } + + const messages: OllamaMessage[] = [ + ...history, + { role: 'user', content: anonymizationResult.anonymizedText }, + ]; + + logger.debug('Sending request to Ollama', { model: session.model || CHAT_MODEL }); + const ollamaResponse = await chat(session.model || CHAT_MODEL, messages); + + const allMappings = await PIIMappingQueries.findBySessionId(id); + const deanonymizedResponse = await deanonymizeText( + ollamaResponse.message.content, + allMappings + ); + + const assistantMessage = await MessageQueries.create( + id, + 'assistant', + deanonymizedResponse, + ollamaResponse.message.content + ); + + await cacheDelete(`${CACHE_PREFIX}${id}`); + await cacheClearPattern(`${CACHE_PREFIX}all`); + + res.json({ + success: true, + data: { + session_id: id, + user_message: userMessage, + assistant_message: assistantMessage, + usage: { + prompt_tokens: ollamaResponse.prompt_eval_count, + completion_tokens: ollamaResponse.eval_count, + total_tokens: (ollamaResponse.prompt_eval_count || 0) + (ollamaResponse.eval_count || 0), + }, + }, + }); + } catch (error) { + logger.error('Chat request failed', { error: (error as Error).message }); + res.status(500).json({ + success: false, + error: 'Chat request failed', + }); + } + } +); + +export default router; diff --git a/backend/src/routes/models.ts b/backend/src/routes/models.ts new file mode 100644 index 0000000..a69e422 --- /dev/null +++ b/backend/src/routes/models.ts @@ -0,0 +1,47 @@ +import { Router, Request, Response } from 'express'; +import { listModels } from '../services/ollama'; +import { cacheGet, cacheSet } from '../services/redis'; +import { createLogger } from '../utils/logger'; +import { OllamaModel } from '../types'; + +const router = Router(); +const logger = createLogger('ModelsRoute'); + +const CACHE_KEY = 'ollama:models'; +const CACHE_TTL = 300; + +router.get('/', async (_req: Request, res: Response) => { + try { + logger.debug('Fetching available models'); + + const cached = await cacheGet(CACHE_KEY); + if (cached) { + logger.debug('Returning cached models'); + res.json({ + success: true, + data: cached, + cached: true, + }); + return; + } + + const response = await listModels(); + const models = response.models || []; + + await cacheSet(CACHE_KEY, models, CACHE_TTL); + + res.json({ + success: true, + data: models, + }); + } catch (error) { + logger.error('Failed to fetch models', { error: (error as Error).message }); + res.status(500).json({ + success: false, + error: 'Failed to fetch models', + details: (error as Error).message, + }); + } +}); + +export default router; diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts new file mode 100644 index 0000000..ee22061 --- /dev/null +++ b/backend/src/routes/sessions.ts @@ -0,0 +1,179 @@ +import { Router, Request, Response } from 'express'; +import { body, param, validationResult } from 'express-validator'; +import { SessionQueries, MessageQueries, PIIMappingQueries } from '../db'; +import { cacheGet, cacheSet, cacheDelete, cacheClearPattern } from '../services/redis'; +import { createLogger } from '../utils/logger'; +import { CreateSessionRequest, Session } from '../types'; + +const router = Router(); +const logger = createLogger('SessionsRoute'); + +const CACHE_PREFIX = 'session:'; +const CACHE_TTL = 3600; + +const handleValidationErrors = (req: Request, res: Response, next: () => void) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + res.status(400).json({ + success: false, + errors: errors.array(), + }); + return; + } + next(); +}; + +router.get('/', async (_req: Request, res: Response) => { + try { + logger.debug('Fetching all sessions'); + + const cached = await cacheGet(`${CACHE_PREFIX}all`); + if (cached) { + logger.debug('Returning cached sessions'); + res.json({ + success: true, + data: cached, + cached: true, + }); + return; + } + + const sessions = await SessionQueries.findAll(); + await cacheSet(`${CACHE_PREFIX}all`, sessions, CACHE_TTL); + + res.json({ + success: true, + data: sessions, + }); + } catch (error) { + logger.error('Failed to fetch sessions', { error: (error as Error).message }); + res.status(500).json({ + success: false, + error: 'Failed to fetch sessions', + }); + } +}); + +router.post( + '/', + [ + body('name').isString().trim().notEmpty().withMessage('Name is required'), + body('model').optional().isString().trim(), + body('metadata').optional().isObject(), + handleValidationErrors, + ], + async (req: Request, res: Response) => { + try { + const { name, model, metadata }: CreateSessionRequest = req.body; + + logger.debug('Creating new session', { name, model }); + + const newSession = await SessionQueries.create( + name, + model || process.env.CHAT_MODEL || 'llama3.2', + metadata + ); + + await cacheClearPattern(`${CACHE_PREFIX}all`); + + res.status(201).json({ + success: true, + data: newSession, + }); + } catch (error) { + logger.error('Failed to create session', { error: (error as Error).message }); + res.status(500).json({ + success: false, + error: 'Failed to create session', + }); + } + } +); + +router.get( + '/:id', + [param('id').isUUID().withMessage('Invalid session ID'), handleValidationErrors], + async (req: Request, res: Response) => { + try { + const { id } = req.params; + logger.debug('Fetching session', { id }); + + const cached = await cacheGet(`${CACHE_PREFIX}${id}`); + if (cached) { + logger.debug('Returning cached session'); + res.json({ + success: true, + data: cached, + cached: true, + }); + return; + } + + const session = await SessionQueries.findById(id); + if (!session) { + res.status(404).json({ + success: false, + error: 'Session not found', + }); + return; + } + + const messages = await MessageQueries.findBySessionId(id); + + const result = { + ...session, + messages, + }; + + await cacheSet(`${CACHE_PREFIX}${id}`, result, CACHE_TTL); + + res.json({ + success: true, + data: result, + }); + } catch (error) { + logger.error('Failed to fetch session', { error: (error as Error).message }); + res.status(500).json({ + success: false, + error: 'Failed to fetch session', + }); + } + } +); + +router.delete( + '/:id', + [param('id').isUUID().withMessage('Invalid session ID'), handleValidationErrors], + async (req: Request, res: Response) => { + try { + const { id } = req.params; + logger.debug('Deleting session', { id }); + + const deleted = await SessionQueries.delete(id); + + if (!deleted) { + res.status(404).json({ + success: false, + error: 'Session not found', + }); + return; + } + + await cacheDelete(`${CACHE_PREFIX}${id}`); + await cacheClearPattern(`${CACHE_PREFIX}all`); + + res.json({ + success: true, + message: 'Session deleted successfully', + }); + } catch (error) { + logger.error('Failed to delete session', { error: (error as Error).message }); + res.status(500).json({ + success: false, + error: 'Failed to delete session', + }); + } + } +); + +export default router; diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..fc7c8b2 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,149 @@ +import express, { Request, Response, NextFunction } from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import morgan from 'morgan'; +import dotenv from 'dotenv'; + +import { createLogger } from './utils/logger'; +import { initDB, closePool } from './db'; +import { connectRedis, disconnectRedis } from './services/redis'; +import { checkHealth as checkAnonymizerHealth } from './services/anonymizer'; + +import sessionsRouter from './routes/sessions'; +import chatRouter from './routes/chat'; +import modelsRouter from './routes/models'; + +dotenv.config(); + +const logger = createLogger('Server'); +const app = express(); +const PORT = parseInt(process.env.PORT || '3000', 10); + +app.use(helmet()); +app.use(cors({ + origin: process.env.CORS_ORIGIN || '*', + methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], +})); +app.use(express.json({ limit: '10mb' })); +app.use(morgan('combined', { + stream: { + write: (message: string) => logger.info(message.trim()), + }, +})); + +app.use('/api/sessions', sessionsRouter); +app.use('/api/sessions', chatRouter); +app.use('/api/models', modelsRouter); + +app.get('/health', async (_req: Request, res: Response) => { + const dbHealthy = true; + const redisHealthy = true; + const anonymizerHealthy = await checkAnonymizerHealth(); + + const isHealthy = dbHealthy && redisHealthy; + + res.status(isHealthy ? 200 : 503).json({ + status: isHealthy ? 'healthy' : 'unhealthy', + timestamp: new Date().toISOString(), + services: { + database: dbHealthy ? 'connected' : 'disconnected', + redis: redisHealthy ? 'connected' : 'disconnected', + anonymizer: anonymizerHealthy ? 'available' : 'unavailable', + }, + version: process.env.npm_package_version || '1.0.0', + }); +}); + +app.get('/ready', async (_req: Request, res: Response) => { + try { + const anonymizerHealthy = await checkAnonymizerHealth(); + + if (anonymizerHealthy) { + res.status(200).json({ + ready: true, + timestamp: new Date().toISOString(), + }); + } else { + res.status(503).json({ + ready: false, + error: 'Anonymizer service unavailable', + }); + } + } catch (error) { + res.status(503).json({ + ready: false, + error: 'Health check failed', + }); + } +}); + +app.use('*', (_req: Request, res: Response) => { + res.status(404).json({ + success: false, + error: 'Endpoint not found', + }); +}); + +app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + logger.error('Unhandled error', { error: err.message, stack: err.stack }); + res.status(500).json({ + success: false, + error: 'Internal server error', + }); +}); + +const shutdown = async (signal: string) => { + logger.info(`Received ${signal}, shutting down gracefully...`); + + try { + await disconnectRedis(); + await closePool(); + logger.info('Cleanup complete, exiting'); + process.exit(0); + } catch (error) { + logger.error('Error during shutdown', { error: (error as Error).message }); + process.exit(1); + } +}; + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); + +process.on('uncaughtException', (error) => { + logger.error('Uncaught exception', { error: error.message, stack: error.stack }); + shutdown('uncaughtException'); +}); + +process.on('unhandledRejection', (reason) => { + logger.error('Unhandled rejection', { reason }); +}); + +const startServer = async () => { + try { + logger.info('Starting Privacy Gateway Backend...'); + + await initDB(); + logger.info('Database initialized'); + + await connectRedis(); + logger.info('Redis connected'); + + const anonymizerHealthy = await checkAnonymizerHealth(); + if (anonymizerHealthy) { + logger.info('Anonymizer service is available'); + } else { + logger.warn('Anonymizer service is not available - chat functionality may be limited'); + } + + app.listen(PORT, () => { + logger.info(`Server running on port ${PORT}`); + logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`); + }); + } catch (error) { + logger.error('Failed to start server', { error: (error as Error).message }); + process.exit(1); + } +}; + +startServer(); diff --git a/backend/src/services/anonymizer.ts b/backend/src/services/anonymizer.ts new file mode 100644 index 0000000..7571262 --- /dev/null +++ b/backend/src/services/anonymizer.ts @@ -0,0 +1,223 @@ +import http from 'http'; +import { createLogger } from '../utils/logger'; +import { AnonymizationResult, OllamaChatResponse, OllamaMessage } from '../types'; + +const logger = createLogger('Anonymizer'); + +const ANON_HOST = process.env.OLLAMA_HOST || 'localhost'; +const ANON_PORT = parseInt(process.env.OLLAMA_PORT || '11434', 10); +const ANON_MODEL = process.env.ANONYMIZATION_MODEL || 'llama3.2'; + +interface RequestOptions { + hostname: string; + port: number; + path: string; + method: string; + headers?: Record; +} + +const makeRequest = ( + options: RequestOptions, + postData?: string +): Promise => { + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: options.hostname, + port: options.port, + path: options.path, + method: options.method, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }, + (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + try { + resolve(JSON.parse(data) as T); + } catch { + resolve(data as unknown as T); + } + } else { + reject(new Error(`HTTP ${res.statusCode}: ${data}`)); + } + }); + } + ); + + req.on('error', (err) => { + reject(err); + }); + + req.setTimeout(30000, () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + + if (postData) { + req.write(postData); + } + + req.end(); + }); +}; + +const ANONYMIZATION_PROMPT = `Du bist ein PII (Personally Identifiable Information) Anonymisierungssystem. + +Deine Aufgabe ist es, sensible Informationen in Texten zu erkennen und durch Platzhalter zu ersetzen. + +Ersetze folgende PII-Typen: +- PERSON: Namen von Personen (z.B. "Max Mustermann", "Anna Müller") +- EMAIL: E-Mail-Adressen +- PHONE: Telefonnummern +- ADDRESS: Adressen (Straße, Hausnummer, PLZ, Ort) +- ORG: Organisationen, Firmen, Behörden +- ID: Ausweisnummern, Kundennummern, IDs +- DATE: Geburtsdaten, spezifische persönliche Daten +- FINANCIAL: IBAN, Kreditkartennummern, Kontonummern + +Format: Ersetze durch [PERSONTYPE_1], [EMAIL_1], [PHONE_1], etc. + +Antworte ausschließlich mit einem JSON-Objekt im folgenden Format: +{ + "anonymizedText": "Der anonymisierte Text mit Platzhaltern", + "mappings": [ + { + "pii_type": "PERSON", + "original_value": "Max Mustermann", + "anonymized_value": "[PERSON_1]" + } + ] +} + +Falls keine PII gefunden wird: +{ + "anonymizedText": "Originaltext ohne Änderungen", + "mappings": [] +} + +Hier ist der zu anonymisierende Text: +`; + +export const anonymizeText = async (text: string): Promise => { + logger.debug('Anonymizing text', { textLength: text.length }); + + const messages: OllamaMessage[] = [ + { + role: 'system', + content: 'Du bist ein präzises PII-Anonymisierungssystem. Antworte immer mit gültigem JSON.', + }, + { + role: 'user', + content: ANONYMIZATION_PROMPT + text, + }, + ]; + + const requestBody = { + model: ANON_MODEL, + messages, + stream: false, + format: 'json', + options: { + temperature: 0.0, + num_predict: 2048, + }, + }; + + try { + const response = await makeRequest( + { + hostname: ANON_HOST, + port: ANON_PORT, + path: '/api/chat', + method: 'POST', + }, + JSON.stringify(requestBody) + ); + + const content = response.message?.content || '{}'; + + let result: AnonymizationResult; + try { + result = JSON.parse(content) as AnonymizationResult; + } catch (parseError) { + logger.warn('Failed to parse anonymization response as JSON, attempting extraction', { + content: content.substring(0, 200), + }); + + const jsonMatch = content.match(/\{[\s\S]*\}/); + if (jsonMatch) { + result = JSON.parse(jsonMatch[0]) as AnonymizationResult; + } else { + throw new Error('No JSON found in response'); + } + } + + if (!result.anonymizedText) { + result.anonymizedText = text; + } + if (!result.mappings) { + result.mappings = []; + } + + logger.debug('Anonymization complete', { + mappingsCount: result.mappings.length, + piiTypes: result.mappings.map(m => m.pii_type), + }); + + return result; + } catch (error) { + logger.error('Anonymization failed', { error: (error as Error).message }); + + return { + anonymizedText: text, + mappings: [], + }; + } +}; + +export const deanonymizeText = async ( + text: string, + mappings: Array<{ pii_type: string; original_value: string; anonymized_value: string }> +): Promise => { + logger.debug('Deanonymizing text', { textLength: text.length, mappingsCount: mappings.length }); + + let deanonymized = text; + + const sortedMappings = [...mappings].sort((a, b) => + b.anonymized_value.length - a.anonymized_value.length + ); + + for (const mapping of sortedMappings) { + const regex = new RegExp( + mapping.anonymized_value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + 'g' + ); + deanonymized = deanonymized.replace(regex, mapping.original_value); + } + + logger.debug('Deanonymization complete'); + return deanonymized; +}; + +export const checkHealth = async (): Promise => { + try { + await makeRequest({ + hostname: ANON_HOST, + port: ANON_PORT, + path: '/api/tags', + method: 'GET', + }); + return true; + } catch { + return false; + } +}; diff --git a/backend/src/services/ollama.ts b/backend/src/services/ollama.ts new file mode 100644 index 0000000..98f4a88 --- /dev/null +++ b/backend/src/services/ollama.ts @@ -0,0 +1,230 @@ +import http from 'http'; +import { createLogger } from '../utils/logger'; +import { OllamaChatRequest, OllamaChatResponse, OllamaListResponse, OllamaMessage } from '../types'; + +const logger = createLogger('Ollama'); + +const OLLAMA_HOST = process.env.OLLAMA_TARGET_HOST || process.env.OLLAMA_HOST || 'localhost'; +const OLLAMA_PORT = parseInt(process.env.OLLAMA_TARGET_PORT || process.env.OLLAMA_PORT || '11434', 10); + +interface RequestOptions { + hostname: string; + port: number; + path: string; + method: string; + headers?: Record; +} + +const makeRequest = ( + options: RequestOptions, + postData?: string +): Promise => { + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: options.hostname, + port: options.port, + path: options.path, + method: options.method, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }, + (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + try { + resolve(JSON.parse(data) as T); + } catch { + resolve(data as unknown as T); + } + } else { + reject(new Error(`HTTP ${res.statusCode}: ${data}`)); + } + }); + } + ); + + req.on('error', (err) => { + reject(err); + }); + + req.setTimeout(30000, () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + + if (postData) { + req.write(postData); + } + + req.end(); + }); +}; + +export const listModels = async (): Promise => { + logger.debug('Fetching model list'); + try { + const response = await makeRequest({ + hostname: OLLAMA_HOST, + port: OLLAMA_PORT, + path: '/api/tags', + method: 'GET', + }); + logger.debug(`Found ${response.models?.length || 0} models`); + return response; + } catch (error) { + logger.error('Failed to list models', { error: (error as Error).message }); + throw error; + } +}; + +export const chat = async ( + model: string, + messages: OllamaMessage[], + options?: { temperature?: number; top_p?: number; top_k?: number; num_predict?: number } +): Promise => { + logger.debug('Sending chat request', { model, messageCount: messages.length }); + + const requestBody: OllamaChatRequest = { + model, + messages, + stream: false, + options: { + temperature: options?.temperature ?? 0.7, + top_p: options?.top_p ?? 0.9, + ...options, + }, + }; + + try { + const response = await makeRequest({ + hostname: OLLAMA_HOST, + port: OLLAMA_PORT, + path: '/api/chat', + method: 'POST', + }, JSON.stringify(requestBody)); + + logger.debug('Chat response received', { + done: response.done, + evalCount: response.eval_count, + }); + + return response; + } catch (error) { + logger.error('Chat request failed', { error: (error as Error).message }); + throw error; + } +}; + +export const chatStream = ( + model: string, + messages: OllamaMessage[], + onData: (chunk: OllamaChatResponse) => void, + onError: (error: Error) => void, + options?: { temperature?: number; top_p?: number; top_k?: number; num_predict?: number } +): Promise => { + return new Promise((resolve, reject) => { + logger.debug('Starting streaming chat', { model, messageCount: messages.length }); + + const requestBody: OllamaChatRequest = { + model, + messages, + stream: true, + options: { + temperature: options?.temperature ?? 0.7, + top_p: options?.top_p ?? 0.9, + ...options, + }, + }; + + const req = http.request( + { + hostname: OLLAMA_HOST, + port: OLLAMA_PORT, + path: '/api/chat', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, + (res) => { + let buffer = ''; + + res.on('data', (chunk: Buffer) => { + buffer += chunk.toString(); + + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim()) { + try { + const parsed = JSON.parse(line) as OllamaChatResponse; + onData(parsed); + } catch (err) { + logger.warn('Failed to parse stream chunk', { line }); + } + } + } + }); + + res.on('end', () => { + if (buffer.trim()) { + try { + const parsed = JSON.parse(buffer) as OllamaChatResponse; + onData(parsed); + } catch (err) { + logger.warn('Failed to parse final chunk', { buffer }); + } + } + resolve(); + }); + + res.on('error', (err) => { + onError(err); + reject(err); + }); + } + ); + + req.on('error', (err) => { + logger.error('Stream request error', { error: err.message }); + onError(err); + reject(err); + }); + + req.setTimeout(300000, () => { + req.destroy(); + const timeoutError = new Error('Streaming request timeout'); + onError(timeoutError); + reject(timeoutError); + }); + + req.write(JSON.stringify(requestBody)); + req.end(); + }); +}; + +export const getModelInfo = async (modelName: string): Promise => { + logger.debug('Fetching model info', { model: modelName }); + try { + const response = await makeRequest({ + hostname: OLLAMA_HOST, + port: OLLAMA_PORT, + path: `/api/show`, + method: 'POST', + }, JSON.stringify({ name: modelName })); + return response; + } catch (error) { + logger.error('Failed to get model info', { error: (error as Error).message }); + throw error; + } +}; diff --git a/backend/src/services/redis.ts b/backend/src/services/redis.ts new file mode 100644 index 0000000..ef88562 --- /dev/null +++ b/backend/src/services/redis.ts @@ -0,0 +1,80 @@ +import { createClient, RedisClientType } from 'redis'; +import { createLogger } from '../utils/logger'; + +const logger = createLogger('Redis'); + +const redisClient: RedisClientType = createClient({ + socket: { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379', 10), + }, +}); + +redisClient.on('error', (err) => { + logger.error('Redis client error', { error: err.message }); +}); + +redisClient.on('connect', () => { + logger.info('Redis client connected'); +}); + +let isConnected = false; + +export const connectRedis = async (): Promise => { + if (!isConnected) { + await redisClient.connect(); + isConnected = true; + } +}; + +export const getRedisClient = (): RedisClientType => { + if (!isConnected) { + throw new Error('Redis client not connected. Call connectRedis() first.'); + } + return redisClient; +}; + +export const disconnectRedis = async (): Promise => { + if (isConnected) { + await redisClient.quit(); + isConnected = false; + logger.info('Redis client disconnected'); + } +}; + +export const cacheGet = async (key: string): Promise => { + try { + const value = await redisClient.get(key); + return value ? JSON.parse(value) : null; + } catch (error) { + logger.error('Cache get error', { error: (error as Error).message, key }); + return null; + } +}; + +export const cacheSet = async (key: string, value: T, ttlSeconds = 3600): Promise => { + try { + await redisClient.setEx(key, ttlSeconds, JSON.stringify(value)); + } catch (error) { + logger.error('Cache set error', { error: (error as Error).message, key }); + } +}; + +export const cacheDelete = async (key: string): Promise => { + try { + await redisClient.del(key); + } catch (error) { + logger.error('Cache delete error', { error: (error as Error).message, key }); + } +}; + +export const cacheClearPattern = async (pattern: string): Promise => { + try { + const keys = await redisClient.keys(pattern); + if (keys.length > 0) { + await redisClient.del(keys); + } + } catch (error) { + logger.error('Cache clear pattern error', { error: (error as Error).message, pattern }); + } +}; diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts new file mode 100644 index 0000000..4ce5e23 --- /dev/null +++ b/backend/src/types/index.ts @@ -0,0 +1,104 @@ +export interface Session { + id: string; + name: string; + model: string; + created_at: Date; + updated_at: Date; + metadata?: Record; +} + +export interface Message { + id: string; + session_id: string; + role: 'user' | 'assistant' | 'system'; + original_content: string; + anonymized_content: string; + created_at: Date; +} + +export interface PIIMapping { + id: string; + session_id: string; + message_id: string; + pii_type: string; + original_value: string; + anonymized_value: string; + created_at: Date; +} + +export interface CreateSessionRequest { + name: string; + model?: string; + metadata?: Record; +} + +export interface ChatRequest { + message: string; + stream?: boolean; +} + +export interface ChatResponse { + session_id: string; + message: Message; + response?: string; +} + +export interface OllamaMessage { + role: 'user' | 'assistant' | 'system'; + content: string; +} + +export interface OllamaChatRequest { + model: string; + messages: OllamaMessage[]; + stream?: boolean; + options?: { + temperature?: number; + top_p?: number; + top_k?: number; + num_predict?: number; + }; +} + +export interface OllamaChatResponse { + model: string; + created_at: string; + message: OllamaMessage; + done: boolean; + done_reason?: string; + total_duration?: number; + load_duration?: number; + prompt_eval_count?: number; + prompt_eval_duration?: number; + eval_count?: number; + eval_duration?: number; +} + +export interface OllamaModel { + name: string; + model: string; + modified_at: string; + size: number; + digest: string; + details?: { + parent_model?: string; + format?: string; + family?: string; + families?: string[]; + parameter_size?: string; + quantization_level?: string; + }; +} + +export interface OllamaListResponse { + models: OllamaModel[]; +} + +export interface AnonymizationResult { + anonymizedText: string; + mappings: Array<{ + pii_type: string; + original_value: string; + anonymized_value: string; + }>; +} diff --git a/backend/src/utils/logger.ts b/backend/src/utils/logger.ts new file mode 100644 index 0000000..d1ae45b --- /dev/null +++ b/backend/src/utils/logger.ts @@ -0,0 +1,39 @@ +import winston from 'winston'; + +const { combine, timestamp, json, printf, colorize, errors } = winston.format; + +const devFormat = printf(({ level, message, timestamp, ...metadata }) => { + let msg = `${timestamp} [${level}]: ${message}`; + if (Object.keys(metadata).length > 0) { + msg += ` ${JSON.stringify(metadata)}`; + } + return msg; +}); + +export const createLogger = (service: string): winston.Logger => { + const isDev = process.env.NODE_ENV !== 'production'; + const logLevel = process.env.LOG_LEVEL || (isDev ? 'debug' : 'info'); + + return winston.createLogger({ + level: logLevel, + defaultMeta: { service }, + format: isDev + ? combine( + colorize(), + timestamp({ format: 'HH:mm:ss' }), + errors({ stack: true }), + devFormat + ) + : combine( + timestamp(), + json() + ), + transports: [ + new winston.transports.Console({ + stderrLevels: ['error'], + }), + ], + }); +}; + +export const logger = createLogger('App'); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..1b97e1c --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "sourceMap": true, + "declaration": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8d3f877 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,110 @@ +version: '3.8' + +services: + # PostgreSQL für Sessions und PII-Mappings + postgres: + image: postgres:15-alpine + container_name: pg-privacy-gateway + environment: + POSTGRES_DB: privacy_gateway + POSTGRES_USER: pguser + POSTGRES_PASSWORD: ${DB_PASSWORD:-pgsecret123} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + networks: + - privacy-net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pguser -d privacy_gateway"] + interval: 5s + timeout: 5s + retries: 5 + + # Anonymisierungs-Service (lokales LLM via Ollama) + ollama-anonymizer: + image: ollama/ollama:latest + container_name: ollama-anonymizer + volumes: + - ollama_models:/root/.ollama + environment: + - OLLAMA_KEEP_ALIVE=24h + networks: + - privacy-net + # GPU Support (optional) + # deploy: + # resources: + # reservations: + # devices: + # - driver: nvidia + # count: 1 + # capabilities: [gpu] + + # Redis für Caching und Session-State + redis: + image: redis:7-alpine + container_name: redis-privacy-gateway + volumes: + - redis_data:/data + networks: + - privacy-net + + # Backend API (wird vom Team entwickelt) + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: privacy-gateway-api + environment: + - NODE_ENV=production + - DB_HOST=postgres + - DB_PORT=5432 + - DB_NAME=privacy_gateway + - DB_USER=pguser + - DB_PASSWORD=${DB_PASSWORD:-pgsecret123} + - REDIS_HOST=redis + - REDIS_PORT=6379 + - OLLAMA_HOST=ollama-anonymizer + - OLLAMA_PORT=11434 + - ANONYMIZATION_MODEL=gemma4:latest + - CHAT_MODEL=${CHAT_MODEL:-llama3.2:latest} + - OLLAMA_TARGET_HOST=${OLLAMA_TARGET_HOST:-host.docker.internal} + - OLLAMA_TARGET_PORT=11434 + ports: + - "${API_PORT:-3000}:3000" + volumes: + - ./backend/src:/app/src + - backend_uploads:/app/uploads + networks: + - privacy-net + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + ollama-anonymizer: + condition: service_started + + # Frontend (wird vom Team entwickelt) + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: privacy-gateway-ui + environment: + - REACT_APP_API_URL=http://localhost:3000 + ports: + - "${UI_PORT:-8080}:80" + networks: + - privacy-net + depends_on: + - backend + +volumes: + postgres_data: + ollama_models: + redis_data: + backend_uploads: + +networks: + privacy-net: + driver: bridge diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..29bfa51 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,11 @@ +node_modules +dist +.git +.gitignore +README.md +.env +.env.local +.DS_Store +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..718d06a --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +# API URL fuer den Backend-Server +VITE_API_URL=http://localhost:3000 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..d438069 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,49 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built assets from builder +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx config for SPA +RUN echo 'server { \ + listen 80; \ + server_name localhost; \ + root /usr/share/nginx/html; \ + index index.html; \ + \ + location / { \ + try_files $uri $uri/ /index.html; \ + } \ + \ + location /api { \ + proxy_pass http://backend:3000; \ + proxy_http_version 1.1; \ + proxy_set_header Upgrade $http_upgrade; \ + proxy_set_header Connection "upgrade"; \ + proxy_set_header Host $host; \ + proxy_set_header X-Real-IP $remote_addr; \ + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \ + proxy_set_header X-Forwarded-Proto $scheme; \ + } \ +}' > /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..c524e1c --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,53 @@ +# Privacy Gateway Frontend + +React-basierte Chat-Oberfläche für das Privacy Gateway. + +## Features + +- ✨ Multi-Window Chat-Sessions (wie OpenWebUI) +- 🎨 Dark Mode Support +- 📝 Markdown-Rendering mit Syntax-Highlighting +- 👁️ Markdown-Live-Preview beim Schreiben +- 📱 Responsive Design +- 🔄 Streaming-Response Support + +## Tech Stack + +- React 18 + TypeScript +- Vite +- Tailwind CSS +- React Markdown + Syntax Highlighter +- Lucide Icons + +## Setup + +```bash +# Dependencies installieren +npm install + +# Development Server starten +npm run dev + +# Production Build +npm run build +``` + +## API-Endpunkte + +Die Frontend-Anwendung kommuniziert mit dem Backend über: + +- `GET /api/sessions` - Alle Sessions laden +- `POST /api/sessions` - Neue Session erstellen +- `GET /api/sessions/:id` - Einzelne Session laden +- `POST /api/sessions/:id/chat` - Nachricht senden (Stream) +- `DELETE /api/sessions/:id` - Session löschen + +## Docker + +```bash +# Image bauen +docker build -t privacy-gateway-frontend . + +# Container starten +docker run -p 80:80 privacy-gateway-frontend +``` diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..badf484 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + Privacy Gateway Chat + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9536484 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "privacy-gateway-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^9.0.1", + "react-syntax-highlighter": "^15.5.0", + "remark-gfm": "^4.0.0", + "lucide-react": "^0.344.0" + }, + "devDependencies": { + "@types/react": "^18.2.55", + "@types/react-dom": "^18.2.19", + "@types/react-syntax-highlighter": "^15.5.11", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.18", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.1", + "typescript": "^5.2.2", + "vite": "^5.1.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..8c51c67 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,127 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Sidebar } from './components/Sidebar'; +import { ChatWindow } from './components/ChatWindow'; +import { Session } from './types'; + +const API_URL = 'http://localhost:3000'; + +function App() { + const [sessions, setSessions] = useState([]); + const [activeSessionId, setActiveSessionId] = useState(null); + const [isDarkMode, setIsDarkMode] = useState(true); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Load sessions + const loadSessions = useCallback(async () => { + try { + const response = await fetch(`${API_URL}/api/sessions`); + if (!response.ok) throw new Error('Failed to load sessions'); + const data = await response.json(); + setSessions(data.sessions || []); + } catch (err) { + setError('Verbindung zum Server fehlgeschlagen'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadSessions(); + }, [loadSessions]); + + // Create new session + const handleCreateSession = async () => { + try { + const response = await fetch(`${API_URL}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: `Chat ${sessions.length + 1}` }), + }); + if (!response.ok) throw new Error('Failed to create session'); + const data = await response.json(); + setSessions((prev) => [data.session, ...prev]); + setActiveSessionId(data.session.id); + } catch (err) { + setError('Konnte keinen neuen Chat erstellen'); + } + }; + + // Delete session + const handleDeleteSession = async (id: string) => { + try { + const response = await fetch(`${API_URL}/api/sessions/${id}`, { + method: 'DELETE', + }); + if (!response.ok) throw new Error('Failed to delete session'); + setSessions((prev) => prev.filter((s) => s.id !== id)); + if (activeSessionId === id) { + setActiveSessionId(null); + } + } catch (err) { + setError('Konnte Chat nicht loeschen'); + } + }; + + // Select session + const handleSelectSession = async (id: string) => { + try { + const response = await fetch(`${API_URL}/api/sessions/${id}`); + if (!response.ok) throw new Error('Failed to load session'); + const data = await response.json(); + setSessions((prev) => + prev.map((s) => (s.id === id ? data.session : s)) + ); + setActiveSessionId(id); + } catch (err) { + setError('Konnte Chat nicht laden'); + } + }; + + // Update session locally + const handleSessionUpdate = (updatedSession: Session) => { + setSessions((prev) => + prev.map((s) => (s.id === updatedSession.id ? updatedSession : s)) + ); + }; + + const activeSession = sessions.find((s) => s.id === activeSessionId) || null; + + if (isLoading) { + return ( +
+
+
+ ); + } + + return ( +
+ + + setIsDarkMode(!isDarkMode)} + onSessionUpdate={handleSessionUpdate} + /> +
+ ); +} + +export default App; diff --git a/frontend/src/components/ChatWindow.tsx b/frontend/src/components/ChatWindow.tsx new file mode 100644 index 0000000..9836282 --- /dev/null +++ b/frontend/src/components/ChatWindow.tsx @@ -0,0 +1,262 @@ +import React, { useState, useCallback } from 'react'; +import { MessageList } from './MessageList'; +import { InputArea } from './InputArea'; +import { Session, Message } from '../types'; +import { Moon, Sun, Bot } from 'lucide-react'; + +const API_URL = 'http://localhost:3000'; + +interface ChatWindowProps { + session: Session | null; + isDarkMode: boolean; + onToggleDarkMode: () => void; + onSessionUpdate: (session: Session) => void; +} + +export const ChatWindow: React.FC = ({ + session, + isDarkMode, + onToggleDarkMode, + onSessionUpdate, +}) => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSendMessage = useCallback( + async (content: string) => { + if (!session) return; + + setIsLoading(true); + setError(null); + + // Optimistic user message + const userMessage: Message = { + id: `temp-${Date.now()}`, + role: 'user', + content, + timestamp: Date.now(), + }; + + const updatedMessages = [...session.messages, userMessage]; + onSessionUpdate({ + ...session, + messages: updatedMessages, + updatedAt: Date.now(), + }); + + try { + const response = await fetch(`${API_URL}/api/sessions/${session.id}/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ message: content }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('No response body'); + } + + let assistantContent = ''; + const assistantMessage: Message = { + id: `assistant-${Date.now()}`, + role: 'assistant', + content: '', + timestamp: Date.now(), + }; + + const messagesWithAssistant = [...updatedMessages, assistantMessage]; + onSessionUpdate({ + ...session, + messages: messagesWithAssistant, + updatedAt: Date.now(), + }); + + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6); + if (data === '[DONE]') { + break; + } + try { + const parsed = JSON.parse(data); + if (parsed.content) { + assistantContent += parsed.content; + onSessionUpdate({ + ...session, + messages: [ + ...updatedMessages, + { ...assistantMessage, content: assistantContent }, + ], + updatedAt: Date.now(), + }); + } + } catch { + // Ignore malformed JSON + } + } + } + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); + // Revert to messages without the failed request + onSessionUpdate({ + ...session, + messages: session.messages, + updatedAt: Date.now(), + }); + } finally { + setIsLoading(false); + } + }, + [session, onSessionUpdate] + ); + + if (!session) { + return ( +
+
+ +

+ Willkommen beim Privacy Gateway +

+

+ Waehle einen bestehenden Chat oder starte einen neuen. +

+
+

+ + Lokal gehostet +

+

+ + Datenschutz-fokussiert +

+

+ + Open Source +

+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+

+ {session.title || 'Neuer Chat'} +

+

+ {session.messages.length} Nachrichten +

+
+
+ +
+ +
+
+ + {/* Error Message */} + {error && ( +
+ Fehler: {error} + +
+ )} + + {/* Messages */} + + + {/* Input */} + +
+ ); +}; diff --git a/frontend/src/components/InputArea.tsx b/frontend/src/components/InputArea.tsx new file mode 100644 index 0000000..5d18635 --- /dev/null +++ b/frontend/src/components/InputArea.tsx @@ -0,0 +1,127 @@ +import React, { useState, KeyboardEvent, useRef, useEffect } from 'react'; +import { Send, Loader2, Eye, EyeOff } from 'lucide-react'; + +interface InputAreaProps { + onSend: (message: string) => void; + isLoading: boolean; + isDarkMode: boolean; + disabled?: boolean; +} + +export const InputArea: React.FC = ({ + onSend, + isLoading, + isDarkMode, + disabled, +}) => { + const [input, setInput] = useState(''); + const [showPreview, setShowPreview] = useState(false); + const textareaRef = useRef(null); + + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`; + } + }, [input]); + + const handleSend = () => { + if (input.trim() && !isLoading && !disabled) { + onSend(input.trim()); + setInput(''); + setShowPreview(false); + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + } + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + return ( +
+ {showPreview && input.trim() && ( +
+
+ Markdown-Vorschau: +
+
+ {input.split('\n').map((line, i) => ( +
{line || '\u00A0'}
+ ))} +
+
+ )} + +
+
+