Initial: Privacy Gateway Projekt mit Team-Implementierung
This commit is contained in:
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 <T = unknown>(
|
||||
text: string,
|
||||
params?: unknown[]
|
||||
): Promise<QueryResult<T>> => {
|
||||
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<void> => {
|
||||
await pool.end();
|
||||
logger.info('Database pool closed');
|
||||
};
|
||||
|
||||
export const initDB = async (): Promise<void> => {
|
||||
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<string, unknown>): Promise<Session> => {
|
||||
const result = await query<Session>(
|
||||
'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<Session[]> => {
|
||||
const result = await query<Session>(
|
||||
'SELECT * FROM sessions ORDER BY updated_at DESC'
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
findById: async (id: string): Promise<Session | null> => {
|
||||
const result = await query<Session>(
|
||||
'SELECT * FROM sessions WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
},
|
||||
|
||||
update: async (id: string, updates: Partial<Session>): Promise<Session | null> => {
|
||||
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<Session>(
|
||||
`UPDATE sessions SET ${setClauses.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
|
||||
values
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
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<Message> => {
|
||||
const result = await query<Message>(
|
||||
'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<Message[]> => {
|
||||
const result = await query<Message>(
|
||||
'SELECT * FROM messages WHERE session_id = $1 ORDER BY created_at ASC',
|
||||
[sessionId]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
findById: async (id: string): Promise<Message | null> => {
|
||||
const result = await query<Message>(
|
||||
'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<PIIMapping> => {
|
||||
const result = await query<PIIMapping>(
|
||||
'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<PIIMapping[]> => {
|
||||
const result = await query<PIIMapping>(
|
||||
'SELECT * FROM pii_mappings WHERE session_id = $1',
|
||||
[sessionId]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
findByMessageId: async (messageId: string): Promise<PIIMapping[]> => {
|
||||
const result = await query<PIIMapping>(
|
||||
'SELECT * FROM pii_mappings WHERE message_id = $1',
|
||||
[messageId]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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<OllamaModel[]>(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;
|
||||
@@ -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<Session[]>(`${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<Session & { messages: unknown[] }>(`${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;
|
||||
@@ -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();
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
const makeRequest = <T>(
|
||||
options: RequestOptions,
|
||||
postData?: string
|
||||
): Promise<T> => {
|
||||
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<AnonymizationResult> => {
|
||||
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<OllamaChatResponse>(
|
||||
{
|
||||
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<string> => {
|
||||
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<boolean> => {
|
||||
try {
|
||||
await makeRequest<unknown>({
|
||||
hostname: ANON_HOST,
|
||||
port: ANON_PORT,
|
||||
path: '/api/tags',
|
||||
method: 'GET',
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
const makeRequest = <T>(
|
||||
options: RequestOptions,
|
||||
postData?: string
|
||||
): Promise<T> => {
|
||||
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<OllamaListResponse> => {
|
||||
logger.debug('Fetching model list');
|
||||
try {
|
||||
const response = await makeRequest<OllamaListResponse>({
|
||||
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<OllamaChatResponse> => {
|
||||
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<OllamaChatResponse>({
|
||||
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<void> => {
|
||||
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<unknown> => {
|
||||
logger.debug('Fetching model info', { model: modelName });
|
||||
try {
|
||||
const response = await makeRequest<unknown>({
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -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<void> => {
|
||||
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<void> => {
|
||||
if (isConnected) {
|
||||
await redisClient.quit();
|
||||
isConnected = false;
|
||||
logger.info('Redis client disconnected');
|
||||
}
|
||||
};
|
||||
|
||||
export const cacheGet = async <T>(key: string): Promise<T | null> => {
|
||||
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 <T>(key: string, value: T, ttlSeconds = 3600): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
export interface Session {
|
||||
id: string;
|
||||
name: string;
|
||||
model: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
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;
|
||||
}>;
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user