Initial commit
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# .env
|
||||||
|
DATABASE_URL=postgresql://vecuser:vecpass@postgres:5432/vectordb
|
||||||
|
LITELLM_PROXY_URL=http://litellm:4000
|
||||||
|
LITELLM_MASTER_KEY=sk-master-key
|
||||||
|
|
||||||
|
# Komma-getrennte Admin User IDs
|
||||||
|
ADMIN_USER_IDS=admin-1,admin-2
|
||||||
58
Dockerfile
Normal file
58
Dockerfile
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --upgrade pip && \
|
||||||
|
pip wheel --no-cache-dir --wheel-dir /build/wheels -r requirements.txt
|
||||||
|
|
||||||
|
|
||||||
|
FROM python:3.12-slim AS runtime
|
||||||
|
|
||||||
|
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpq5 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /build/wheels /wheels
|
||||||
|
RUN pip install --no-cache-dir --no-index --find-links=/wheels /wheels/* \
|
||||||
|
&& rm -rf /wheels
|
||||||
|
|
||||||
|
COPY app/__init__.py ./app/__init__.py
|
||||||
|
COPY app/main.py ./app/main.py
|
||||||
|
COPY app/auth.py ./app/auth.py
|
||||||
|
COPY app/database.py ./app/database.py
|
||||||
|
COPY app/models.py ./app/models.py
|
||||||
|
COPY app/routers/__init__.py ./app/routers/__init__.py
|
||||||
|
COPY app/routers/stores.py ./app/routers/stores.py
|
||||||
|
COPY app/routers/documents.py ./app/routers/documents.py
|
||||||
|
COPY app/routers/admin.py ./app/routers/admin.py
|
||||||
|
COPY app/routers/openai_compat.py ./app/routers/openai_compat.py
|
||||||
|
COPY app/utils/__init__.py ./app/utils/__init__.py
|
||||||
|
COPY app/utils/stats.py ./app/utils/stats.py
|
||||||
|
COPY app/utils/chunking.py ./app/utils/chunking.py
|
||||||
|
|
||||||
|
RUN find /app -type f | sort
|
||||||
|
|
||||||
|
RUN chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD python -c "import httpx; httpx.get('http://localhost:8000/health')" || exit 1
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", \
|
||||||
|
"--host", "0.0.0.0", \
|
||||||
|
"--port", "8000", \
|
||||||
|
"--workers", "4", \
|
||||||
|
"--loop", "uvloop", \
|
||||||
|
"--http", "httptools"]
|
||||||
354
README.md
Normal file
354
README.md
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
# litellm-vector-store
|
||||||
|
|
||||||
|
A vector store service built on top of [LiteLLM](https://github.com/BerriAI/litellm) and [pgvector](https://github.com/pgvector/pgvector), providing an OpenAI-compatible API for semantic search, document storage and Retrieval Augmented Generation (RAG).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🔐 **Authentication** via LiteLLM API Keys
|
||||||
|
- 🗄️ **Vector Store** powered by PostgreSQL + pgvector
|
||||||
|
- 🔍 **Semantic Search** with optional Reranking
|
||||||
|
- 🤖 **RAG Endpoint** - Search + LLM in one request
|
||||||
|
- 📄 **File Upload** - PDF, DOCX, TXT, Markdown
|
||||||
|
- 🧩 **OpenAI-compatible API** - works with existing OpenAI SDKs
|
||||||
|
- 👥 **Multi-User** - Store permissions per user
|
||||||
|
- 🖥️ **Admin UI** - Manage users, stores and permissions
|
||||||
|
- 📊 **Usage Tracking** - Track requests per user
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Client (API Key)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
LiteLLM Proxy ──────────────────────┐
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
Vector Store API Embedding Models
|
||||||
|
│ (via LiteLLM)
|
||||||
|
▼
|
||||||
|
PostgreSQL + pgvector
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Kubernetes Cluster
|
||||||
|
- PostgreSQL with pgvector extension
|
||||||
|
- LiteLLM Proxy (deployed)
|
||||||
|
- Container Registry
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Clone Repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/your-org/litellm-vector-store.git
|
||||||
|
cd litellm-vector-store
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Database Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl exec -it <postgres-pod> -n <namespace> \
|
||||||
|
-- psql -U postgres -d vectordb << 'EOF'
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vector_stores (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
owner_user_id VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
store_id UUID REFERENCES vector_stores(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
embedding vector(1024),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS store_permissions (
|
||||||
|
store_id UUID REFERENCES vector_stores(id) ON DELETE CASCADE,
|
||||||
|
user_id VARCHAR(255) NOT NULL,
|
||||||
|
permission VARCHAR(50) DEFAULT 'read',
|
||||||
|
PRIMARY KEY (store_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS usage_stats (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id VARCHAR(255) NOT NULL,
|
||||||
|
store_id UUID REFERENCES vector_stores(id) ON DELETE SET NULL,
|
||||||
|
action VARCHAR(50) NOT NULL,
|
||||||
|
tokens INT DEFAULT 0,
|
||||||
|
duration FLOAT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_store
|
||||||
|
ON documents(store_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_embedding
|
||||||
|
ON documents USING ivfflat (embedding vector_cosine_ops)
|
||||||
|
WITH (lists = 100);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_user
|
||||||
|
ON usage_stats(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_created
|
||||||
|
ON usage_stats(created_at);
|
||||||
|
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO vecuser;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO vecuser;
|
||||||
|
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl create secret generic vector-api-secrets \
|
||||||
|
--namespace vector-store \
|
||||||
|
--from-literal=DATABASE_URL="postgresql://vecuser:pass@postgres:5432/vectordb" \
|
||||||
|
--from-literal=LITELLM_MASTER_KEY="sk-master-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# k8s/configmap.yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: vector-store-config
|
||||||
|
namespace: vector-store
|
||||||
|
data:
|
||||||
|
LITELLM_PROXY_URL: "http://litellm.<namespace>.svc.cluster.local:4000"
|
||||||
|
ADMIN_USER_IDS: "your-admin-user-id"
|
||||||
|
API_URL: "https://api.your-domain.com"
|
||||||
|
EMBEDDING_MODEL: "your-embedding-model"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Build & Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API
|
||||||
|
docker build -t your-registry/vector-store-api:1.0.0 .
|
||||||
|
docker push your-registry/vector-store-api:1.0.0
|
||||||
|
|
||||||
|
# Admin UI
|
||||||
|
docker build \
|
||||||
|
-t your-registry/vector-store-admin:1.0.0 \
|
||||||
|
./ui
|
||||||
|
docker push your-registry/vector-store-admin:1.0.0
|
||||||
|
|
||||||
|
# Deploy
|
||||||
|
kubectl apply -f k8s/namespace.yaml
|
||||||
|
kubectl apply -f k8s/configmap.yaml
|
||||||
|
kubectl apply -f k8s/secrets.yaml
|
||||||
|
kubectl apply -f k8s/vector-api/
|
||||||
|
kubectl apply -f k8s/admin-ui/
|
||||||
|
kubectl apply -f k8s/ingress-api.yaml
|
||||||
|
kubectl apply -f k8s/ingress-ui.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
litellm-vector-store/
|
||||||
|
├── app/ # FastAPI Backend
|
||||||
|
│ ├── main.py # Application entry point
|
||||||
|
│ ├── auth.py # LiteLLM authentication
|
||||||
|
│ ├── database.py # PostgreSQL connection
|
||||||
|
│ ├── models.py # Pydantic models
|
||||||
|
│ ├── routers/
|
||||||
|
│ │ ├── stores.py # Vector store CRUD
|
||||||
|
│ │ ├── documents.py # Document management
|
||||||
|
│ │ ├── admin.py # Admin endpoints
|
||||||
|
│ │ └── openai_compat.py # OpenAI-compatible API
|
||||||
|
│ └── utils/
|
||||||
|
│ ├── chunking.py # Text chunking
|
||||||
|
│ └── stats.py # Usage tracking
|
||||||
|
├── ui/ # React Admin UI
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ │ ├── Login.tsx
|
||||||
|
│ │ │ ├── Dashboard.tsx
|
||||||
|
│ │ │ ├── Users.tsx
|
||||||
|
│ │ │ └── Stores.tsx
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── Layout.tsx
|
||||||
|
│ │ │ └── PermissionModal.tsx
|
||||||
|
│ │ └── api/
|
||||||
|
│ │ └── client.ts
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── k8s/ # Kubernetes manifests
|
||||||
|
│ ├── namespace.yaml
|
||||||
|
│ ├── configmap.yaml
|
||||||
|
│ ├── secrets.yaml
|
||||||
|
│ ├── vector-api/
|
||||||
|
│ │ ├── deployment.yaml
|
||||||
|
│ │ └── service.yaml
|
||||||
|
│ ├── admin-ui/
|
||||||
|
│ │ ├── deployment.yaml
|
||||||
|
│ │ └── service.yaml
|
||||||
|
│ ├── ingress-api.yaml
|
||||||
|
│ └── ingress-ui.yaml
|
||||||
|
├── scripts/
|
||||||
|
│ └── init.sql # Database initialization
|
||||||
|
├── Dockerfile
|
||||||
|
├── requirements.txt
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Base URL
|
||||||
|
|
||||||
|
```
|
||||||
|
https://api.your-domain.com/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer sk-your-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `POST` | `/v1/vector_stores` | Create store |
|
||||||
|
| `GET` | `/v1/vector_stores` | List stores |
|
||||||
|
| `GET` | `/v1/vector_stores/{id}` | Get store |
|
||||||
|
| `DELETE` | `/v1/vector_stores/{id}` | Delete store |
|
||||||
|
| `POST` | `/v1/vector_stores/{id}/files` | Add texts |
|
||||||
|
| `GET` | `/v1/vector_stores/{id}/files` | List files |
|
||||||
|
| `DELETE` | `/v1/vector_stores/{id}/files/{file_id}` | Delete file |
|
||||||
|
| `POST` | `/v1/vector_stores/{id}/upload` | Upload file |
|
||||||
|
| `POST` | `/v1/vector_stores/{id}/search` | Search |
|
||||||
|
| `POST` | `/v1/vector_stores/{id}/rag` | RAG query |
|
||||||
|
| `POST` | `/v1/embeddings` | Create embeddings |
|
||||||
|
| `GET` | `/v1/embeddings/models` | List embedding models |
|
||||||
|
| `GET` | `/v1/models` | List all models |
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
client = httpx.Client(
|
||||||
|
base_url="https://api.your-domain.com/v1",
|
||||||
|
headers={"Authorization": "Bearer sk-your-key"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create store
|
||||||
|
store = client.post(
|
||||||
|
"/vector_stores",
|
||||||
|
json={"name": "My Knowledge Base"}
|
||||||
|
).json()
|
||||||
|
|
||||||
|
# Upload file
|
||||||
|
with open("document.pdf", "rb") as f:
|
||||||
|
client.post(
|
||||||
|
f"/vector_stores/{store['id']}/upload",
|
||||||
|
files={"file": f}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Search
|
||||||
|
results = client.post(
|
||||||
|
f"/vector_stores/{store['id']}/search",
|
||||||
|
json={"query": "What is FastAPI?", "top_k": 3}
|
||||||
|
).json()
|
||||||
|
|
||||||
|
# RAG
|
||||||
|
answer = client.post(
|
||||||
|
f"/vector_stores/{store['id']}/rag",
|
||||||
|
json={"query": "What is FastAPI?"}
|
||||||
|
).json()
|
||||||
|
print(answer["answer"])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Reference
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `DATABASE_URL` | ✅ | — | PostgreSQL connection URL |
|
||||||
|
| `LITELLM_PROXY_URL` | ✅ | — | LiteLLM proxy URL |
|
||||||
|
| `LITELLM_MASTER_KEY` | ✅ | — | LiteLLM master key |
|
||||||
|
| `ADMIN_USER_IDS` | ✅ | — | Comma-separated admin user IDs |
|
||||||
|
| `EMBEDDING_MODEL` | ❌ | `text-embedding-ada-002` | Default embedding model |
|
||||||
|
|
||||||
|
### Supported File Formats
|
||||||
|
|
||||||
|
| Format | Extension | Notes |
|
||||||
|
|--------|-----------|-------|
|
||||||
|
| Text | `.txt` | UTF-8 encoded |
|
||||||
|
| PDF | `.pdf` | Text PDFs only, no scans |
|
||||||
|
| Word | `.docx` | Microsoft Word 2007+ |
|
||||||
|
| Markdown | `.md` | Standard Markdown |
|
||||||
|
|
||||||
|
### Limits
|
||||||
|
|
||||||
|
| Limit | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Max file size | 256 MB |
|
||||||
|
| Max search results | 50 |
|
||||||
|
| Request timeout | 600 seconds |
|
||||||
|
| Default chunk size | 512 characters |
|
||||||
|
| Default chunk overlap | 50 characters |
|
||||||
|
|
||||||
|
## Admin UI
|
||||||
|
|
||||||
|
The Admin UI is available at `https://admin.your-domain.com`.
|
||||||
|
|
||||||
|
Login with your Admin API Key to:
|
||||||
|
|
||||||
|
- 📊 View usage statistics
|
||||||
|
- 👥 Manage users and their stores
|
||||||
|
- 🔑 Rotate API keys
|
||||||
|
- 🔒 Grant/revoke store permissions
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Run locally
|
||||||
|
DATABASE_URL="postgresql://..." \
|
||||||
|
LITELLM_PROXY_URL="http://..." \
|
||||||
|
LITELLM_MASTER_KEY="sk-..." \
|
||||||
|
ADMIN_USER_IDS="your-id" \
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
|
||||||
|
# Run UI locally
|
||||||
|
cd ui
|
||||||
|
npm install
|
||||||
|
VITE_API_URL=http://localhost:8000 npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Component | Technology |
|
||||||
|
|-----------|-----------|
|
||||||
|
| **API** | FastAPI + Python 3.12 |
|
||||||
|
| **Database** | PostgreSQL 16 + pgvector |
|
||||||
|
| **Auth** | LiteLLM Key Management |
|
||||||
|
| **Embeddings** | Via LiteLLM Proxy |
|
||||||
|
| **Admin UI** | React + TypeScript + Tailwind CSS |
|
||||||
|
| **Container** | Docker + Kubernetes |
|
||||||
|
| **Ingress** | NGINX Ingress Controller |
|
||||||
|
| **TLS** | cert-manager + Let's Encrypt |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - see [LICENSE](LICENSE) for details.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create your feature branch (`git checkout -b feature/my-feature`)
|
||||||
|
3. Commit your changes (`git commit -m 'Add my feature'`)
|
||||||
|
4. Push to the branch (`git push origin feature/my-feature`)
|
||||||
|
5. Open a Pull Request
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
53
app/auth.py
Normal file
53
app/auth.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import httpx
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from fastapi import HTTPException, Header
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LITELLM_URL = os.getenv("LITELLM_PROXY_URL", "http://litellm:4000")
|
||||||
|
MASTER_KEY = os.getenv("LITELLM_MASTER_KEY")
|
||||||
|
|
||||||
|
async def verify_api_key(authorization: str = Header(...)) -> dict:
|
||||||
|
token = authorization.removeprefix("Bearer ")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
# Master Key nutzen um Key-Info abzufragen
|
||||||
|
resp = await client.get(
|
||||||
|
f"{LITELLM_URL}/key/info",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {MASTER_KEY}"
|
||||||
|
},
|
||||||
|
params={"key": token},
|
||||||
|
timeout=5.0
|
||||||
|
)
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"LiteLLM nicht erreichbar: {e}")
|
||||||
|
raise HTTPException(503, f"Auth service unavailable: {e}")
|
||||||
|
|
||||||
|
logger.debug(f"LiteLLM Status: {resp.status_code}")
|
||||||
|
logger.debug(f"LiteLLM Response: {resp.text}")
|
||||||
|
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise HTTPException(401, "Invalid API Key")
|
||||||
|
if resp.status_code == 401:
|
||||||
|
raise HTTPException(401, "Invalid API Key")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise HTTPException(502, f"Auth service error: {resp.status_code}")
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
user_id = (
|
||||||
|
data.get("info", {}).get("user_id") or
|
||||||
|
data.get("user_id")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(400, "API Key hat keine user_id")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": user_id,
|
||||||
|
"token": token,
|
||||||
|
"key_alias": data.get("info", {}).get("key_alias"),
|
||||||
|
}
|
||||||
32
app/database.py
Normal file
32
app/database.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import asyncpg
|
||||||
|
import os
|
||||||
|
from tenacity import retry, stop_after_attempt, wait_fixed
|
||||||
|
|
||||||
|
pool: asyncpg.Pool = None
|
||||||
|
|
||||||
|
@retry(stop=stop_after_attempt(5), wait=wait_fixed(3))
|
||||||
|
async def init_db():
|
||||||
|
global pool
|
||||||
|
|
||||||
|
url = os.getenv("DATABASE_URL")
|
||||||
|
if not url:
|
||||||
|
raise ValueError("DATABASE_URL nicht gesetzt!")
|
||||||
|
|
||||||
|
pool = await asyncpg.create_pool(
|
||||||
|
dsn=url,
|
||||||
|
min_size=2,
|
||||||
|
max_size=10
|
||||||
|
)
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||||
|
|
||||||
|
print(f"✅ Datenbank verbunden")
|
||||||
|
|
||||||
|
async def close_db():
|
||||||
|
global pool
|
||||||
|
if pool:
|
||||||
|
await pool.close()
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
yield conn
|
||||||
35
app/main.py
Normal file
35
app/main.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from app.database import init_db, close_db
|
||||||
|
from app.routers import stores, documents, admin, openai_compat
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await init_db()
|
||||||
|
yield
|
||||||
|
await close_db()
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Vector Store API",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["https://admin.vector.cosair.de"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(stores.router, prefix="/stores", tags=["Stores"])
|
||||||
|
app.include_router(documents.router, prefix="/documents", tags=["Documents"])
|
||||||
|
app.include_router(admin.router, prefix="/admin", tags=["Admin"])
|
||||||
|
|
||||||
|
app.include_router(openai_compat.router, prefix="/v1", tags=["OpenAI Compatible"])
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
35
app/middleware/rate_limit.py
Normal file
35
app/middleware/rate_limit.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from fastapi import Request, HTTPException
|
||||||
|
from collections import defaultdict
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Einfaches In-Memory Rate Limiting
|
||||||
|
request_counts: dict = defaultdict(list)
|
||||||
|
|
||||||
|
RATE_LIMITS = {
|
||||||
|
"search": (100, 60), # 100 Requests pro 60 Sekunden
|
||||||
|
"upsert": (50, 60),
|
||||||
|
"embed": (200, 60),
|
||||||
|
"rag": (20, 60),
|
||||||
|
}
|
||||||
|
|
||||||
|
def check_rate_limit(user_id: str, action: str):
|
||||||
|
limit, window = RATE_LIMITS.get(action, (100, 60))
|
||||||
|
now = time.time()
|
||||||
|
key = f"{user_id}:{action}"
|
||||||
|
|
||||||
|
# Alte Requests entfernen
|
||||||
|
request_counts[key] = [
|
||||||
|
t for t in request_counts[key]
|
||||||
|
if now - t < window
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(request_counts[key]) >= limit:
|
||||||
|
raise HTTPException(429, {
|
||||||
|
"error": {
|
||||||
|
"message": f"Rate limit erreicht: {limit} Requests pro {window}s",
|
||||||
|
"type": "rate_limit_error",
|
||||||
|
"code": "rate_limit_exceeded"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
request_counts[key].append(now)
|
||||||
27
app/models.py
Normal file
27
app/models.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
class StoreCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
|
||||||
|
class StoreResponse(BaseModel):
|
||||||
|
store_id: UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
class UpsertRequest(BaseModel):
|
||||||
|
store_id: UUID
|
||||||
|
texts: list[str] = Field(..., min_length=1)
|
||||||
|
metadata: list[dict] = []
|
||||||
|
|
||||||
|
class QueryRequest(BaseModel):
|
||||||
|
store_id: UUID
|
||||||
|
query: str = Field(..., min_length=1)
|
||||||
|
top_k: int = Field(default=5, ge=1, le=50)
|
||||||
|
filter: Optional[dict] = None
|
||||||
|
|
||||||
|
class QueryResult(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
content: str
|
||||||
|
metadata: dict
|
||||||
|
similarity: float
|
||||||
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
186
app/routers/admin.py
Normal file
186
app/routers/admin.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from app.auth import verify_api_key
|
||||||
|
from app.database import get_db
|
||||||
|
import httpx
|
||||||
|
import os
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
LITELLM_URL = os.getenv("LITELLM_PROXY_URL", "http://litellm:4000")
|
||||||
|
MASTER_KEY = os.getenv("LITELLM_MASTER_KEY")
|
||||||
|
ADMIN_IDS = os.getenv("ADMIN_USER_IDS", "").split(",")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Admin-Check ---
|
||||||
|
async def require_admin(user: dict = Depends(verify_api_key)):
|
||||||
|
if user["user_id"] not in ADMIN_IDS:
|
||||||
|
raise HTTPException(403, "Admin-Zugriff erforderlich")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# --- Stats ---
|
||||||
|
@router.get("/stats")
|
||||||
|
async def get_stats(
|
||||||
|
admin=Depends(require_admin),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
stats = await db.fetchrow(
|
||||||
|
"""SELECT
|
||||||
|
(SELECT COUNT(*) FROM vector_stores) AS total_stores,
|
||||||
|
(SELECT COUNT(*) FROM documents) AS total_documents,
|
||||||
|
(SELECT COUNT(DISTINCT owner_user_id)
|
||||||
|
FROM vector_stores) AS total_users,
|
||||||
|
(SELECT COUNT(*) FROM store_permissions) AS total_permissions"""
|
||||||
|
)
|
||||||
|
return dict(stats)
|
||||||
|
|
||||||
|
|
||||||
|
# --- User Endpoints ---
|
||||||
|
@router.get("/users")
|
||||||
|
async def list_users(
|
||||||
|
admin=Depends(require_admin),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
rows = await db.fetch(
|
||||||
|
"""SELECT
|
||||||
|
owner_user_id AS user_id,
|
||||||
|
COUNT(id) AS store_count,
|
||||||
|
MAX(created_at) AS last_activity
|
||||||
|
FROM vector_stores
|
||||||
|
GROUP BY owner_user_id
|
||||||
|
ORDER BY last_activity DESC"""
|
||||||
|
)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}/stores")
|
||||||
|
async def get_user_stores(
|
||||||
|
user_id: str,
|
||||||
|
admin=Depends(require_admin),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
rows = await db.fetch(
|
||||||
|
"""SELECT
|
||||||
|
vs.id,
|
||||||
|
vs.name,
|
||||||
|
vs.created_at,
|
||||||
|
COUNT(d.id) AS document_count
|
||||||
|
FROM vector_stores vs
|
||||||
|
LEFT JOIN documents d ON d.store_id = vs.id
|
||||||
|
WHERE vs.owner_user_id = $1
|
||||||
|
GROUP BY vs.id, vs.name, vs.created_at""",
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}/stores/{store_id}")
|
||||||
|
async def admin_delete_store(
|
||||||
|
user_id: str,
|
||||||
|
store_id: str,
|
||||||
|
admin=Depends(require_admin),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
deleted = await db.fetchval(
|
||||||
|
"""DELETE FROM vector_stores
|
||||||
|
WHERE id = $1 AND owner_user_id = $2
|
||||||
|
RETURNING id""",
|
||||||
|
store_id, user_id
|
||||||
|
)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(404, "Store nicht gefunden")
|
||||||
|
return {"deleted": str(deleted)}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Permission Endpoints ---
|
||||||
|
@router.get("/stores/{store_id}/permissions")
|
||||||
|
async def get_permissions(
|
||||||
|
store_id: str,
|
||||||
|
admin=Depends(require_admin),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
rows = await db.fetch(
|
||||||
|
"""SELECT user_id, permission, created_at
|
||||||
|
FROM store_permissions
|
||||||
|
WHERE store_id = $1""",
|
||||||
|
store_id
|
||||||
|
)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stores/{store_id}/permissions")
|
||||||
|
async def grant_permission(
|
||||||
|
store_id: str,
|
||||||
|
user_id: str,
|
||||||
|
permission: str = "read",
|
||||||
|
admin=Depends(require_admin),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
if permission not in ("read", "write", "admin"):
|
||||||
|
raise HTTPException(400, "Ungültige Permission: read, write oder admin")
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO store_permissions (store_id, user_id, permission)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (store_id, user_id)
|
||||||
|
DO UPDATE SET permission = $3""",
|
||||||
|
store_id, user_id, permission
|
||||||
|
)
|
||||||
|
return {"granted": permission, "user_id": user_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/stores/{store_id}/permissions/{user_id}")
|
||||||
|
async def revoke_permission(
|
||||||
|
store_id: str,
|
||||||
|
user_id: str,
|
||||||
|
admin=Depends(require_admin),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
await db.execute(
|
||||||
|
"DELETE FROM store_permissions WHERE store_id=$1 AND user_id=$2",
|
||||||
|
store_id, user_id
|
||||||
|
)
|
||||||
|
return {"revoked": user_id}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Key Management ---
|
||||||
|
@router.post("/users/{user_id}/rotate-key")
|
||||||
|
async def rotate_key(
|
||||||
|
user_id: str,
|
||||||
|
admin=Depends(require_admin),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{LITELLM_URL}/key/generate",
|
||||||
|
headers={"Authorization": f"Bearer {MASTER_KEY}"},
|
||||||
|
json={
|
||||||
|
"user_id": user_id,
|
||||||
|
"key_alias": f"{user_id}-rotated"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise HTTPException(500, "Key-Rotation fehlgeschlagen")
|
||||||
|
|
||||||
|
store_count = await db.fetchval(
|
||||||
|
"SELECT COUNT(*) FROM vector_stores WHERE owner_user_id=$1",
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"new_key": resp.json()["key"],
|
||||||
|
"user_id": user_id,
|
||||||
|
"stores_preserved": store_count
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/verify")
|
||||||
|
async def verify_admin(
|
||||||
|
admin=Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Prüft ob der API Key Admin-Rechte hat.
|
||||||
|
Gibt 200 zurück wenn Admin, 403 wenn nicht.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"admin": True,
|
||||||
|
"user_id": admin["user_id"],
|
||||||
|
}
|
||||||
41
app/routers/chunking.py
Normal file
41
app/routers/chunking.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
def chunk_text(
|
||||||
|
text: str,
|
||||||
|
chunk_size: int = 512,
|
||||||
|
overlap: int = 50,
|
||||||
|
separator: str = "\n"
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Text in ueberlappende Chunks aufteilen
|
||||||
|
chunk_size: Max Zeichen pro Chunk
|
||||||
|
overlap: Ueberlappung zwischen Chunks
|
||||||
|
separator: Trennzeichen fuer saubere Splits
|
||||||
|
"""
|
||||||
|
# Zuerst an Separatoren splitten
|
||||||
|
paragraphs = text.split(separator)
|
||||||
|
chunks = []
|
||||||
|
current = ""
|
||||||
|
index = 0
|
||||||
|
|
||||||
|
for para in paragraphs:
|
||||||
|
if len(current) + len(para) > chunk_size and current:
|
||||||
|
chunks.append({
|
||||||
|
"text": current.strip(),
|
||||||
|
"index": index,
|
||||||
|
"start": text.find(current.strip()),
|
||||||
|
})
|
||||||
|
# Overlap: letzten Teil behalten
|
||||||
|
current = current[-overlap:] + para
|
||||||
|
index += 1
|
||||||
|
else:
|
||||||
|
current += separator + para
|
||||||
|
|
||||||
|
if current.strip():
|
||||||
|
chunks.append({
|
||||||
|
"text": current.strip(),
|
||||||
|
"index": index,
|
||||||
|
"start": text.find(current.strip()),
|
||||||
|
})
|
||||||
|
|
||||||
|
return chunks
|
||||||
113
app/routers/documents.py
Normal file
113
app/routers/documents.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from app.auth import verify_api_key
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import UpsertRequest, QueryRequest
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LITELLM_URL = os.getenv("LITELLM_PROXY_URL", "http://litellm:4000")
|
||||||
|
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "text-embedding-ada-002")
|
||||||
|
|
||||||
|
|
||||||
|
async def _embed(text: str, token: str) -> list[float]:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{LITELLM_URL}/embeddings",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": EMBEDDING_MODEL,
|
||||||
|
"input": text
|
||||||
|
},
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(f"Embedding Fehler: {resp.status_code} - {resp.text}")
|
||||||
|
raise HTTPException(
|
||||||
|
502,
|
||||||
|
f"Embedding fehlgeschlagen: {resp.status_code} - {resp.text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return resp.json()["data"][0]["embedding"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_access(db, store_id: str, user_id: str):
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"SELECT owner_user_id FROM vector_stores WHERE id=$1", store_id
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Store nicht gefunden")
|
||||||
|
if row["owner_user_id"] != user_id:
|
||||||
|
shared = await db.fetchval(
|
||||||
|
"SELECT 1 FROM store_permissions WHERE store_id=$1 AND user_id=$2",
|
||||||
|
store_id, user_id
|
||||||
|
)
|
||||||
|
if not shared:
|
||||||
|
raise HTTPException(403, "Kein Zugriff")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upsert")
|
||||||
|
async def upsert(
|
||||||
|
body: UpsertRequest,
|
||||||
|
user: dict = Depends(verify_api_key),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
await _check_access(db, str(body.store_id), user["user_id"])
|
||||||
|
|
||||||
|
ids = []
|
||||||
|
for i, text in enumerate(body.texts):
|
||||||
|
embedding = await _embed(text, user["token"])
|
||||||
|
meta = body.metadata[i] if i < len(body.metadata) else {}
|
||||||
|
|
||||||
|
doc_id = await db.fetchval(
|
||||||
|
"""INSERT INTO documents (store_id, content, metadata, embedding)
|
||||||
|
VALUES ($1, $2, $3, $4::vector) RETURNING id""",
|
||||||
|
str(body.store_id),
|
||||||
|
text,
|
||||||
|
json.dumps(meta),
|
||||||
|
str(embedding)
|
||||||
|
)
|
||||||
|
ids.append(str(doc_id))
|
||||||
|
|
||||||
|
return {"inserted": len(ids), "ids": ids}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/query")
|
||||||
|
async def query(
|
||||||
|
body: QueryRequest,
|
||||||
|
user: dict = Depends(verify_api_key),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
await _check_access(db, str(body.store_id), user["user_id"])
|
||||||
|
|
||||||
|
q_emb = await _embed(body.query, user["token"])
|
||||||
|
|
||||||
|
rows = await db.fetch(
|
||||||
|
"""SELECT id, content, metadata,
|
||||||
|
1 - (embedding <=> $1::vector) AS similarity
|
||||||
|
FROM documents
|
||||||
|
WHERE store_id = $2
|
||||||
|
ORDER BY embedding <=> $1::vector
|
||||||
|
LIMIT $3""",
|
||||||
|
str(q_emb),
|
||||||
|
str(body.store_id),
|
||||||
|
body.top_k
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"results": [
|
||||||
|
{
|
||||||
|
"id": str(r["id"]),
|
||||||
|
"content": r["content"],
|
||||||
|
"metadata": r["metadata"],
|
||||||
|
"similarity": float(r["similarity"])
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]}
|
||||||
35
app/routers/files.py
Normal file
35
app/routers/files.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
|
||||||
|
import pypdf
|
||||||
|
import docx
|
||||||
|
import io
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
async def extract_text(file: UploadFile) -> str:
|
||||||
|
"""Text aus verschiedenen Dateiformaten extrahieren"""
|
||||||
|
content = await file.read()
|
||||||
|
|
||||||
|
if file.filename.endswith(".pdf"):
|
||||||
|
pdf = pypdf.PdfReader(io.BytesIO(content))
|
||||||
|
return "\n".join(page.extract_text() for page in pdf.pages)
|
||||||
|
|
||||||
|
elif file.filename.endswith(".docx"):
|
||||||
|
doc = docx.Document(io.BytesIO(content))
|
||||||
|
return "\n".join(p.text for p in doc.paragraphs)
|
||||||
|
|
||||||
|
elif file.filename.endswith(".txt"):
|
||||||
|
return content.decode("utf-8")
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise HTTPException(400, f"Nicht unterstütztes Format: {file.filename}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/v1/vector_stores/{store_id}/upload")
|
||||||
|
async def upload_file(
|
||||||
|
store_id: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
user: dict = Depends(verify_api_key),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
text = await extract_text(file)
|
||||||
|
chunks = chunk_text(text)
|
||||||
787
app/routers/openai_compat.py
Normal file
787
app/routers/openai_compat.py
Normal file
@@ -0,0 +1,787 @@
|
|||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import pypdf
|
||||||
|
import docx
|
||||||
|
import io
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional
|
||||||
|
from app.auth import verify_api_key
|
||||||
|
from app.database import get_db
|
||||||
|
from app.utils.stats import track_usage
|
||||||
|
from app.utils.chunking import chunk_text
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LITELLM_URL = os.getenv("LITELLM_PROXY_URL", "http://litellm:4000")
|
||||||
|
LITELLM_MASTER = os.getenv("LITELLM_MASTER_KEY")
|
||||||
|
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "cosair/multilingual-e5-large-instruct")
|
||||||
|
|
||||||
|
|
||||||
|
class VectorStoreCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
metadata: dict = {}
|
||||||
|
|
||||||
|
class VectorStoreResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
object: str = "vector_store"
|
||||||
|
name: str
|
||||||
|
metadata: dict = {}
|
||||||
|
created_at: int
|
||||||
|
|
||||||
|
class FileUploadRequest(BaseModel):
|
||||||
|
texts: list[str]
|
||||||
|
metadata: list[dict] = []
|
||||||
|
|
||||||
|
class SearchRequest(BaseModel):
|
||||||
|
query: str
|
||||||
|
top_k: int = Field(default=5, ge=1, le=50)
|
||||||
|
rerank: bool = False
|
||||||
|
rerank_model: Optional[str] = None
|
||||||
|
filters: Optional[dict] = None
|
||||||
|
|
||||||
|
class EmbeddingRequest(BaseModel):
|
||||||
|
input: str | list[str]
|
||||||
|
model: Optional[str] = None
|
||||||
|
encoding_format: Optional[str] = "float"
|
||||||
|
|
||||||
|
class RAGRequest(BaseModel):
|
||||||
|
query: str
|
||||||
|
model: str = "cosair/gemma4:31b"
|
||||||
|
top_k: int = 5
|
||||||
|
rerank: bool = False
|
||||||
|
system_prompt: Optional[str] = None
|
||||||
|
messages: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
# Hilfsfunktionen
|
||||||
|
|
||||||
|
def is_embedding_model(model: dict) -> bool:
|
||||||
|
"""Prueft ob ein Modell ein Embedding Modell ist - nur ueber mode"""
|
||||||
|
mode = (
|
||||||
|
model.get("mode") or
|
||||||
|
model.get("model_info", {}).get("mode")
|
||||||
|
)
|
||||||
|
return mode == "embedding"
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_all_models() -> list[dict]:
|
||||||
|
"""
|
||||||
|
Alle Modelle mit Master Key holen.
|
||||||
|
Master Key gibt korrekte mode Infos fuer alle Modelle zurueck.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{LITELLM_URL}/model_group/info",
|
||||||
|
headers={"Authorization": f"Bearer {LITELLM_MASTER}"},
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
raise HTTPException(503, f"LiteLLM nicht erreichbar: {e}")
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise HTTPException(502, f"Modelle konnten nicht abgerufen werden: {resp.text}")
|
||||||
|
|
||||||
|
models = []
|
||||||
|
for m in resp.json().get("data", []):
|
||||||
|
models.append({
|
||||||
|
**m,
|
||||||
|
"id": m.get("model_group", m.get("id", "")),
|
||||||
|
})
|
||||||
|
|
||||||
|
return models
|
||||||
|
|
||||||
|
|
||||||
|
async def _embed(
|
||||||
|
text: str,
|
||||||
|
token: str,
|
||||||
|
model: Optional[str] = None
|
||||||
|
) -> list[float]:
|
||||||
|
"""Embedding ueber LiteLLM generieren"""
|
||||||
|
use_model = model or EMBEDDING_MODEL
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{LITELLM_URL}/embeddings",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": use_model,
|
||||||
|
"input": text
|
||||||
|
},
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(f"Embedding Fehler: {resp.status_code} - {resp.text}")
|
||||||
|
raise HTTPException(502, f"Embedding fehlgeschlagen: {resp.text}")
|
||||||
|
|
||||||
|
return resp.json()["data"][0]["embedding"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_access(db, store_id: str, user_id: str):
|
||||||
|
"""Zugriff auf Store pruefen"""
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"SELECT owner_user_id FROM vector_stores WHERE id=$1", store_id
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, detail={
|
||||||
|
"error": {
|
||||||
|
"message": f"No vector store found with id '{store_id}'",
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
"code": "not_found"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if row["owner_user_id"] != user_id:
|
||||||
|
shared = await db.fetchval(
|
||||||
|
"SELECT 1 FROM store_permissions WHERE store_id=$1 AND user_id=$2",
|
||||||
|
store_id, user_id
|
||||||
|
)
|
||||||
|
if not shared:
|
||||||
|
raise HTTPException(403, detail={
|
||||||
|
"error": {
|
||||||
|
"message": "You don't have access to this vector store",
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
"code": "permission_denied"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def _rerank(
|
||||||
|
query: str,
|
||||||
|
results: list[dict],
|
||||||
|
model: str,
|
||||||
|
token: str
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Ergebnisse mit Reranker neu sortieren"""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{LITELLM_URL}/rerank",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={
|
||||||
|
"model": model,
|
||||||
|
"query": query,
|
||||||
|
"documents": [r["content"][0]["text"] for r in results]
|
||||||
|
},
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(f"Rerank Fehler: {resp.text}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
reranked = resp.json()["results"]
|
||||||
|
|
||||||
|
return [
|
||||||
|
{**results[r["index"]], "score": r["relevance_score"]}
|
||||||
|
for r in sorted(reranked, key=lambda x: x["relevance_score"], reverse=True)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Models Endpoints
|
||||||
|
|
||||||
|
@router.get("/models")
|
||||||
|
async def list_models(
|
||||||
|
user: dict = Depends(verify_api_key),
|
||||||
|
):
|
||||||
|
"""Alle verfuegbaren Modelle von LiteLLM"""
|
||||||
|
models = await _get_all_models()
|
||||||
|
return {
|
||||||
|
"object": "list",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": m["id"],
|
||||||
|
"object": "model",
|
||||||
|
"mode": m.get("mode"),
|
||||||
|
"owned_by": "system",
|
||||||
|
}
|
||||||
|
for m in models
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models/{model_id:path}")
|
||||||
|
async def get_model(
|
||||||
|
model_id: str,
|
||||||
|
user: dict = Depends(verify_api_key),
|
||||||
|
):
|
||||||
|
"""Einzelnes Modell von LiteLLM"""
|
||||||
|
all_models = await _get_all_models()
|
||||||
|
model_lookup = {m["id"]: m for m in all_models}
|
||||||
|
|
||||||
|
if model_id not in model_lookup:
|
||||||
|
raise HTTPException(404, {
|
||||||
|
"error": {
|
||||||
|
"message": f"Modell '{model_id}' nicht gefunden",
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
"code": "not_found"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
m = model_lookup[model_id]
|
||||||
|
return {
|
||||||
|
"id": m["id"],
|
||||||
|
"object": "model",
|
||||||
|
"mode": m.get("mode"),
|
||||||
|
"owned_by": "system",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Embedding Endpoints
|
||||||
|
|
||||||
|
@router.get("/embeddings/models")
|
||||||
|
async def list_embedding_models(
|
||||||
|
user: dict = Depends(verify_api_key),
|
||||||
|
):
|
||||||
|
"""Nur Embedding Modelle - gefiltert über mode mit Master Key"""
|
||||||
|
all_models = await _get_all_models()
|
||||||
|
|
||||||
|
embedding_models = [
|
||||||
|
{
|
||||||
|
"id": m["id"],
|
||||||
|
"object": "model",
|
||||||
|
"owned_by": "system",
|
||||||
|
"default": m["id"] == EMBEDDING_MODEL,
|
||||||
|
}
|
||||||
|
for m in all_models
|
||||||
|
if is_embedding_model(m)
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"object": "list",
|
||||||
|
"default": EMBEDDING_MODEL,
|
||||||
|
"data": embedding_models
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/embeddings")
|
||||||
|
async def create_embeddings(
|
||||||
|
body: EmbeddingRequest,
|
||||||
|
user: dict = Depends(verify_api_key),
|
||||||
|
):
|
||||||
|
"""Embeddings erstellen - einzeln oder als Liste"""
|
||||||
|
start = time.time()
|
||||||
|
model = body.model or EMBEDDING_MODEL
|
||||||
|
inputs = body.input if isinstance(body.input, list) else [body.input]
|
||||||
|
|
||||||
|
all_models = await _get_all_models()
|
||||||
|
model_lookup = {m["id"]: m for m in all_models}
|
||||||
|
|
||||||
|
if model in model_lookup and not is_embedding_model(model_lookup[model]):
|
||||||
|
raise HTTPException(400, {
|
||||||
|
"error": {
|
||||||
|
"message": f"'{model}' ist kein Embedding Modell",
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
"code": "invalid_model"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
embeddings = []
|
||||||
|
total_tokens = 0
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
for i, text in enumerate(inputs):
|
||||||
|
resp = await client.post(
|
||||||
|
f"{LITELLM_URL}/embeddings",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {user['token']}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
json={"model": model, "input": text},
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(f"Embedding Fehler: {resp.status_code} - {resp.text}")
|
||||||
|
raise HTTPException(502, f"Embedding fehlgeschlagen: {resp.text}")
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
total_tokens += data.get("usage", {}).get("total_tokens", 0)
|
||||||
|
embeddings.append({
|
||||||
|
"object": "embedding",
|
||||||
|
"index": i,
|
||||||
|
"embedding": data["data"][0]["embedding"]
|
||||||
|
})
|
||||||
|
|
||||||
|
await track_usage(
|
||||||
|
user_id=user["user_id"],
|
||||||
|
action="embed",
|
||||||
|
tokens=total_tokens,
|
||||||
|
duration=time.time() - start
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"object": "list",
|
||||||
|
"model": model,
|
||||||
|
"data": embeddings,
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": total_tokens,
|
||||||
|
"total_tokens": total_tokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Vector Store Endpoints
|
||||||
|
|
||||||
|
@router.post("/vector_stores", response_model=VectorStoreResponse)
|
||||||
|
async def create_vector_store(
|
||||||
|
body: VectorStoreCreate,
|
||||||
|
user: dict = Depends(verify_api_key),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Neuen Vector Store anlegen"""
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"""INSERT INTO vector_stores (name, owner_user_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
RETURNING id, name, created_at""",
|
||||||
|
body.name, user["user_id"]
|
||||||
|
)
|
||||||
|
return VectorStoreResponse(
|
||||||
|
id=str(row["id"]),
|
||||||
|
name=row["name"],
|
||||||
|
metadata=body.metadata,
|
||||||
|
created_at=int(row["created_at"].timestamp())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vector_stores")
|
||||||
|
async def list_vector_stores(
|
||||||
|
user: dict = Depends(verify_api_key),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Alle eigenen Vector Stores auflisten"""
|
||||||
|
rows = await db.fetch(
|
||||||
|
"""SELECT vs.id, vs.name, vs.created_at,
|
||||||
|
COUNT(d.id) AS file_counts
|
||||||
|
FROM vector_stores vs
|
||||||
|
LEFT JOIN documents d ON d.store_id = vs.id
|
||||||
|
WHERE vs.owner_user_id = $1
|
||||||
|
GROUP BY vs.id, vs.name, vs.created_at
|
||||||
|
ORDER BY vs.created_at DESC""",
|
||||||
|
user["user_id"]
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"object": "list",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": str(r["id"]),
|
||||||
|
"object": "vector_store",
|
||||||
|
"name": r["name"],
|
||||||
|
"created_at": int(r["created_at"].timestamp()),
|
||||||
|
"file_counts": {"total": r["file_counts"]}
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vector_stores/{store_id}")
|
||||||
|
async def get_vector_store(
|
||||||
|
store_id: str,
|
||||||
|
user: dict = Depends(verify_api_key),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Einzelnen Vector Store abrufen"""
|
||||||
|
await _check_access(db, store_id, user["user_id"])
|
||||||
|
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"""SELECT vs.id, vs.name, vs.created_at,
|
||||||
|
COUNT(d.id) AS file_counts
|
||||||
|
FROM vector_stores vs
|
||||||
|
LEFT JOIN documents d ON d.store_id = vs.id
|
||||||
|
WHERE vs.id = $1
|
||||||
|
GROUP BY vs.id, vs.name, vs.created_at""",
|
||||||
|
store_id
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"object": "vector_store",
|
||||||
|
"name": row["name"],
|
||||||
|
"created_at": int(row["created_at"].timestamp()),
|
||||||
|
"file_counts": {"total": row["file_counts"]}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/vector_stores/{store_id}")
|
||||||
|
async def delete_vector_store(
|
||||||
|
store_id: str,
|
||||||
|
user: dict = Depends(verify_api_key),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Vector Store loeschen"""
|
||||||
|
deleted = await db.fetchval(
|
||||||
|
"""DELETE FROM vector_stores
|
||||||
|
WHERE id=$1 AND owner_user_id=$2
|
||||||
|
RETURNING id""",
|
||||||
|
store_id, user["user_id"]
|
||||||
|
)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(404, "Vector store nicht gefunden")
|
||||||
|
return {
|
||||||
|
"id": store_id,
|
||||||
|
"object": "vector_store.deleted",
|
||||||
|
"deleted": True
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Files Endpoints
|
||||||
|
|
||||||
|
@router.post("/vector_stores/{store_id}/files")
|
||||||
|
async def add_files(
|
||||||
|
store_id: str,
|
||||||
|
body: FileUploadRequest,
|
||||||
|
user: dict = Depends(verify_api_key),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Dokumente in Vector Store einfuegen"""
|
||||||
|
start = time.time()
|
||||||
|
await _check_access(db, store_id, user["user_id"])
|
||||||
|
|
||||||
|
ids = []
|
||||||
|
for i, text in enumerate(body.texts):
|
||||||
|
embedding = await _embed(text, user["token"])
|
||||||
|
meta = body.metadata[i] if i < len(body.metadata) else {}
|
||||||
|
|
||||||
|
doc_id = await db.fetchval(
|
||||||
|
"""INSERT INTO documents (store_id, content, metadata, embedding)
|
||||||
|
VALUES ($1, $2, $3, $4::vector) RETURNING id""",
|
||||||
|
store_id, text, json.dumps(meta), str(embedding)
|
||||||
|
)
|
||||||
|
ids.append(str(doc_id))
|
||||||
|
|
||||||
|
await track_usage(
|
||||||
|
user_id=user["user_id"],
|
||||||
|
action="upsert",
|
||||||
|
store_id=store_id,
|
||||||
|
duration=time.time() - start
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"object": "vector_store.file_batch",
|
||||||
|
"counts": {
|
||||||
|
"completed": len(ids),
|
||||||
|
"failed": 0,
|
||||||
|
"total": len(body.texts)
|
||||||
|
},
|
||||||
|
"ids": ids
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vector_stores/{store_id}/files")
|
||||||
|
async def list_files(
|
||||||
|
store_id: str,
|
||||||
|
user: dict = Depends(verify_api_key),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Alle Dokumente eines Vector Stores auflisten"""
|
||||||
|
await _check_access(db, store_id, user["user_id"])
|
||||||
|
|
||||||
|
rows = await db.fetch(
|
||||||
|
"""SELECT id, content, metadata, created_at
|
||||||
|
FROM documents
|
||||||
|
WHERE store_id=$1
|
||||||
|
ORDER BY created_at DESC""",
|
||||||
|
store_id
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"object": "list",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": str(r["id"]),
|
||||||
|
"object": "vector_store.file",
|
||||||
|
"content": r["content"][:100] + "...",
|
||||||
|
"metadata": r["metadata"],
|
||||||
|
"created_at": int(r["created_at"].timestamp())
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/vector_stores/{store_id}/files/{file_id}")
|
||||||
|
async def delete_file(
|
||||||
|
store_id: str,
|
||||||
|
file_id: str,
|
||||||
|
user: dict = Depends(verify_api_key),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Einzelnes Dokument loeschen"""
|
||||||
|
await _check_access(db, store_id, user["user_id"])
|
||||||
|
|
||||||
|
deleted = await db.fetchval(
|
||||||
|
"DELETE FROM documents WHERE id=$1 AND store_id=$2 RETURNING id",
|
||||||
|
file_id, store_id
|
||||||
|
)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(404, "File nicht gefunden")
|
||||||
|
return {
|
||||||
|
"id": file_id,
|
||||||
|
"object": "vector_store.file.deleted",
|
||||||
|
"deleted": True
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Search Endpoint
|
||||||
|
|
||||||
|
@router.post("/vector_stores/{store_id}/search")
|
||||||
|
async def search(
|
||||||
|
store_id: str,
|
||||||
|
body: SearchRequest,
|
||||||
|
user: dict = Depends(verify_api_key),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Aehnliche Dokumente im Vector Store suchen"""
|
||||||
|
start = time.time()
|
||||||
|
await _check_access(db, store_id, user["user_id"])
|
||||||
|
|
||||||
|
q_emb = await _embed(body.query, user["token"])
|
||||||
|
fetch_k = body.top_k * 3 if body.rerank else body.top_k
|
||||||
|
|
||||||
|
rows = await db.fetch(
|
||||||
|
"""SELECT id, content, metadata,
|
||||||
|
1 - (embedding <=> $1::vector) AS score
|
||||||
|
FROM documents
|
||||||
|
WHERE store_id = $2
|
||||||
|
ORDER BY embedding <=> $1::vector
|
||||||
|
LIMIT $3""",
|
||||||
|
str(q_emb), store_id, fetch_k
|
||||||
|
)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for r in rows:
|
||||||
|
metadata = r["metadata"]
|
||||||
|
if isinstance(metadata, str):
|
||||||
|
try:
|
||||||
|
metadata = json.loads(metadata)
|
||||||
|
except Exception:
|
||||||
|
metadata = {}
|
||||||
|
if metadata is None:
|
||||||
|
metadata = {}
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"id": str(r["id"]),
|
||||||
|
"object": "vector_store.search_result",
|
||||||
|
"score": float(r["score"]),
|
||||||
|
"content": [{"type": "text", "text": r["content"]}],
|
||||||
|
"metadata": metadata
|
||||||
|
})
|
||||||
|
|
||||||
|
if body.rerank:
|
||||||
|
rerank_model = body.rerank_model or "cosair/bge-reranker-v2-m3"
|
||||||
|
results = await _rerank(body.query, results, rerank_model, user["token"])
|
||||||
|
results = results[:body.top_k]
|
||||||
|
|
||||||
|
await track_usage(
|
||||||
|
user_id=user["user_id"],
|
||||||
|
action="search",
|
||||||
|
store_id=store_id,
|
||||||
|
duration=time.time() - start
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"object": "list", "data": results}
|
||||||
|
|
||||||
|
|
||||||
|
# RAG Endpoint
|
||||||
|
|
||||||
|
@router.post("/vector_stores/{store_id}/rag")
|
||||||
|
async def rag(
|
||||||
|
store_id: str,
|
||||||
|
body: RAGRequest,
|
||||||
|
user: dict = Depends(verify_api_key),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Retrieval Augmented Generation"""
|
||||||
|
start = time.time()
|
||||||
|
await _check_access(db, store_id, user["user_id"])
|
||||||
|
|
||||||
|
q_emb = await _embed(body.query, user["token"])
|
||||||
|
fetch_k = body.top_k * 3 if body.rerank else body.top_k
|
||||||
|
|
||||||
|
rows = await db.fetch(
|
||||||
|
"""SELECT id, content, metadata,
|
||||||
|
1 - (embedding <=> $1::vector) AS score
|
||||||
|
FROM documents
|
||||||
|
WHERE store_id = $2
|
||||||
|
ORDER BY embedding <=> $1::vector
|
||||||
|
LIMIT $3""",
|
||||||
|
str(q_emb), store_id, fetch_k
|
||||||
|
)
|
||||||
|
|
||||||
|
results = [
|
||||||
|
{
|
||||||
|
"id": str(r["id"]),
|
||||||
|
"content": r["content"],
|
||||||
|
"score": float(r["score"]),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
if body.rerank:
|
||||||
|
results = await _rerank(
|
||||||
|
body.query, results,
|
||||||
|
"cosair/bge-reranker-v2-m3",
|
||||||
|
user["token"]
|
||||||
|
)
|
||||||
|
results = results[:body.top_k]
|
||||||
|
|
||||||
|
context = "\n\n".join([
|
||||||
|
f"[{i+1}] {r['content']}"
|
||||||
|
for i, r in enumerate(results)
|
||||||
|
])
|
||||||
|
|
||||||
|
system_prompt = body.system_prompt or (
|
||||||
|
"Du bist ein hilfreicher Assistent. "
|
||||||
|
"Beantworte Fragen ausschließlich basierend auf dem gegebenen Kontext. "
|
||||||
|
"Wenn die Antwort nicht im Kontext zu finden ist, sage das ehrlich.\n\n"
|
||||||
|
f"Kontext:\n{context}"
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
*body.messages,
|
||||||
|
{"role": "user", "content": body.query}
|
||||||
|
]
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{LITELLM_URL}/chat/completions",
|
||||||
|
headers={"Authorization": f"Bearer {user['token']}"},
|
||||||
|
json={"model": body.model, "messages": messages},
|
||||||
|
timeout=60.0
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise HTTPException(502, f"LLM Fehler: {resp.text}")
|
||||||
|
|
||||||
|
llm_data = resp.json()
|
||||||
|
answer = llm_data["choices"][0]["message"]["content"]
|
||||||
|
total_tokens = llm_data.get("usage", {}).get("total_tokens", 0)
|
||||||
|
|
||||||
|
await track_usage(
|
||||||
|
user_id=user["user_id"],
|
||||||
|
action="rag",
|
||||||
|
store_id=store_id,
|
||||||
|
tokens=total_tokens,
|
||||||
|
duration=time.time() - start
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"object": "rag.response",
|
||||||
|
"answer": answer,
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"content": r["content"][:200] + "...",
|
||||||
|
"score": r["score"]
|
||||||
|
}
|
||||||
|
for r in results
|
||||||
|
],
|
||||||
|
"model": body.model,
|
||||||
|
"usage": llm_data.get("usage", {})
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/vector_stores/{store_id}/upload")
|
||||||
|
async def upload_file(
|
||||||
|
store_id: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
chunk_size: int = Form(default=512),
|
||||||
|
chunk_overlap: int = Form(default=50),
|
||||||
|
user: dict = Depends(verify_api_key),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Datei hochladen, chunken und in Vector Store speichern"""
|
||||||
|
start = time.time()
|
||||||
|
await _check_access(db, store_id, user["user_id"])
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
filename = file.filename.lower()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if filename.endswith(".pdf"):
|
||||||
|
pdf = pypdf.PdfReader(io.BytesIO(content))
|
||||||
|
text = "\n".join(
|
||||||
|
page.extract_text()
|
||||||
|
for page in pdf.pages
|
||||||
|
if page.extract_text()
|
||||||
|
)
|
||||||
|
|
||||||
|
elif filename.endswith(".docx"):
|
||||||
|
doc = docx.Document(io.BytesIO(content))
|
||||||
|
text = "\n".join(
|
||||||
|
p.text for p in doc.paragraphs if p.text.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
elif filename.endswith(".txt"):
|
||||||
|
text = content.decode("utf-8")
|
||||||
|
|
||||||
|
elif filename.endswith(".md"):
|
||||||
|
text = content.decode("utf-8")
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
f"Nicht unterstütztes Format: {file.filename}. "
|
||||||
|
f"Unterstützt: .pdf, .docx, .txt, .md"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(422, f"Datei konnte nicht gelesen werden: {e}")
|
||||||
|
|
||||||
|
if not text.strip():
|
||||||
|
raise HTTPException(422, "Datei enthaelt keinen Text")
|
||||||
|
|
||||||
|
chunks = chunk_text(
|
||||||
|
text=text,
|
||||||
|
chunk_size=chunk_size,
|
||||||
|
overlap=chunk_overlap
|
||||||
|
)
|
||||||
|
|
||||||
|
ids = []
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for chunk in chunks:
|
||||||
|
try:
|
||||||
|
embedding = await _embed(chunk["text"], user["token"])
|
||||||
|
doc_id = await db.fetchval(
|
||||||
|
"""INSERT INTO documents (store_id, content, metadata, embedding)
|
||||||
|
VALUES ($1, $2, $3, $4::vector) RETURNING id""",
|
||||||
|
store_id,
|
||||||
|
chunk["text"],
|
||||||
|
json.dumps({
|
||||||
|
"source": file.filename,
|
||||||
|
"chunk": chunk["index"],
|
||||||
|
"start": chunk.get("start", 0),
|
||||||
|
}),
|
||||||
|
str(embedding)
|
||||||
|
)
|
||||||
|
ids.append(str(doc_id))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Chunk {chunk['index']} fehlgeschlagen: {e}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
await track_usage(
|
||||||
|
user_id=user["user_id"],
|
||||||
|
action="upload",
|
||||||
|
store_id=store_id,
|
||||||
|
duration=time.time() - start
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"object": "vector_store.file_batch",
|
||||||
|
"filename": file.filename,
|
||||||
|
"counts": {
|
||||||
|
"completed": len(ids),
|
||||||
|
"failed": failed,
|
||||||
|
"total": len(chunks)
|
||||||
|
},
|
||||||
|
"ids": ids
|
||||||
|
}
|
||||||
45
app/routers/stores.py
Normal file
45
app/routers/stores.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from app.auth import verify_api_key
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import StoreCreate, StoreResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("", response_model=StoreResponse)
|
||||||
|
async def create_store(
|
||||||
|
body: StoreCreate,
|
||||||
|
user: dict = Depends(verify_api_key),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
store_id = await db.fetchval(
|
||||||
|
"INSERT INTO vector_stores (name, owner_user_id) VALUES ($1,$2) RETURNING id",
|
||||||
|
body.name, user["user_id"]
|
||||||
|
)
|
||||||
|
return StoreResponse(store_id=store_id, name=body.name)
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_stores(
|
||||||
|
user: dict = Depends(verify_api_key),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
rows = await db.fetch(
|
||||||
|
"SELECT id, name, created_at FROM vector_stores WHERE owner_user_id=$1",
|
||||||
|
user["user_id"]
|
||||||
|
)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
@router.delete("/{store_id}")
|
||||||
|
async def delete_store(
|
||||||
|
store_id: str,
|
||||||
|
user: dict = Depends(verify_api_key),
|
||||||
|
db=Depends(get_db)
|
||||||
|
):
|
||||||
|
deleted = await db.fetchval(
|
||||||
|
"""DELETE FROM vector_stores
|
||||||
|
WHERE id=$1 AND owner_user_id=$2
|
||||||
|
RETURNING id""",
|
||||||
|
store_id, user["user_id"]
|
||||||
|
)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(404, "Store not found or access denied")
|
||||||
|
return {"deleted": str(deleted)}
|
||||||
0
app/utils/__init__.py
Normal file
0
app/utils/__init__.py
Normal file
36
app/utils/chunking.py
Normal file
36
app/utils/chunking.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
def chunk_text(
|
||||||
|
text: str,
|
||||||
|
chunk_size: int = 512,
|
||||||
|
overlap: int = 50,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Text in ueberlappende Chunks aufteilen"""
|
||||||
|
chunks = []
|
||||||
|
start = 0
|
||||||
|
index = 0
|
||||||
|
|
||||||
|
while start < len(text):
|
||||||
|
end = start + chunk_size
|
||||||
|
chunk = text[start:end]
|
||||||
|
|
||||||
|
if end < len(text):
|
||||||
|
last_period = max(
|
||||||
|
chunk.rfind(". "),
|
||||||
|
chunk.rfind(".\n"),
|
||||||
|
chunk.rfind("! "),
|
||||||
|
chunk.rfind("? "),
|
||||||
|
)
|
||||||
|
if last_period > chunk_size // 2:
|
||||||
|
end = start + last_period + 1
|
||||||
|
chunk = text[start:end]
|
||||||
|
|
||||||
|
if chunk.strip():
|
||||||
|
chunks.append({
|
||||||
|
"text": chunk.strip(),
|
||||||
|
"index": index,
|
||||||
|
"start": start,
|
||||||
|
})
|
||||||
|
|
||||||
|
start = end - overlap
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
return chunks
|
||||||
24
app/utils/stats.py
Normal file
24
app/utils/stats.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from app.database import pool
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async def track_usage(
|
||||||
|
user_id: str,
|
||||||
|
action: str,
|
||||||
|
store_id: Optional[str] = None,
|
||||||
|
tokens: int = 0,
|
||||||
|
duration: float = 0
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO usage_stats
|
||||||
|
(user_id, store_id, action, tokens, duration)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)""",
|
||||||
|
user_id, store_id, action, tokens, round(duration, 3)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Tracking Fehler: {e}")
|
||||||
52
docker-compose.yml
Normal file
52
docker-compose.yml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: vectordb
|
||||||
|
POSTGRES_USER: vecuser
|
||||||
|
POSTGRES_PASSWORD: vecpass
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
- ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U vecuser -d vectordb"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
litellm:
|
||||||
|
image: ghcr.io/berriai/litellm:main-latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
volumes:
|
||||||
|
- ./litellm_config.yaml:/app/config.yaml
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
vector-api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: runtime
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
litellm:
|
||||||
|
condition: service_started
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c",
|
||||||
|
"import httpx; httpx.get('http://localhost:8000/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
32
k8s/admin-ui/deployment.yaml
Normal file
32
k8s/admin-ui/deployment.yaml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: admin-ui
|
||||||
|
namespace: vector-store
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: admin-ui
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: admin-ui
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: admin-ui
|
||||||
|
image: harbor.cosair.de/litellm/vector-store-admin
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
env:
|
||||||
|
- name: API_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: vector-store-config
|
||||||
|
key: API_URL
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
11
k8s/admin-ui/service.yaml
Normal file
11
k8s/admin-ui/service.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: admin-ui
|
||||||
|
namespace: vector-store
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: admin-ui
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 80
|
||||||
10
k8s/configmap.yaml
Normal file
10
k8s/configmap.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: vector-store-config
|
||||||
|
namespace: vector-store
|
||||||
|
data:
|
||||||
|
LITELLM_PROXY_URL: "https://llmproxy.cosiar.de"
|
||||||
|
ADMIN_USER_IDS: "default_user_id"
|
||||||
|
API_URL: "https://api.vector.cosair.de"
|
||||||
|
EMBEDDING_MODEL: "cosair/multilingual-e5-large-instruct"
|
||||||
33
k8s/ingress-api.yaml
Normal file
33
k8s/ingress-api.yaml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: vector-api-ingress
|
||||||
|
namespace: vector-store
|
||||||
|
annotations:
|
||||||
|
kubernetes.io/ingress.class: "nginx"
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
nginx.ingress.kubernetes.io/enable-cors: "true"
|
||||||
|
nginx.ingress.kubernetes.io/cors-allow-origin: "https://admin.vector.cosair.de"
|
||||||
|
nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, PUT, DELETE, OPTIONS"
|
||||||
|
nginx.ingress.kubernetes.io/cors-allow-headers: "Authorization, Content-Type"
|
||||||
|
nginx.ingress.kubernetes.io/cors-allow-credentials: "true"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-body-size: "256m"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-connect-timeout: "600"
|
||||||
|
spec:
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- api.vector.cosair.de
|
||||||
|
secretName: vector-api-tls
|
||||||
|
rules:
|
||||||
|
- host: api.vector.cosair.de
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: vector-api
|
||||||
|
port:
|
||||||
|
number: 8000
|
||||||
24
k8s/ingress-ui.yaml
Normal file
24
k8s/ingress-ui.yaml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: admin-ui-ingress
|
||||||
|
namespace: vector-store
|
||||||
|
annotations:
|
||||||
|
kubernetes.io/ingress.class: "nginx"
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
spec:
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- admin.vector.cosair.de
|
||||||
|
secretName: admin-ui-tls
|
||||||
|
rules:
|
||||||
|
- host: admin.vector.cosair.de
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: admin-ui
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
4
k8s/namespace.yaml
Normal file
4
k8s/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: vector-store
|
||||||
9
k8s/secrets.yaml
Normal file
9
k8s/secrets.yaml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: vector-api-secrets
|
||||||
|
namespace: vector-store
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
LITELLM_MASTER_KEY: "sk-master-key"
|
||||||
|
DATABASE_URL: "postgresql://vecuser:vecpass@postgres:5432/vectordb"
|
||||||
64
k8s/vector-api/deployment.yaml
Normal file
64
k8s/vector-api/deployment.yaml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: vector-api
|
||||||
|
namespace: vector-store
|
||||||
|
spec:
|
||||||
|
replicas: 3
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: vector-api
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: vector-api
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: vector-api
|
||||||
|
image: your-registry/vector-store-api:1.0.0
|
||||||
|
ports:
|
||||||
|
- containerPort: 8000
|
||||||
|
env:
|
||||||
|
- name: DATABASE_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: vector-api-secrets
|
||||||
|
key: DATABASE_URL
|
||||||
|
|
||||||
|
- name: LITELLM_MASTER_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: vector-api-secrets
|
||||||
|
key: LITELLM_MASTER_KEY
|
||||||
|
|
||||||
|
- name: LITELLM_PROXY_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: vector-store-config
|
||||||
|
key: LITELLM_PROXY_URL
|
||||||
|
|
||||||
|
- name: ADMIN_USER_IDS
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: vector-store-config
|
||||||
|
key: ADMIN_USER_IDS
|
||||||
|
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 5
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 15
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "128Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
11
k8s/vector-api/service.yaml
Normal file
11
k8s/vector-api/service.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: vector-api
|
||||||
|
namespace: vector-store
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: vector-api
|
||||||
|
ports:
|
||||||
|
- port: 8000
|
||||||
|
targetPort: 8000
|
||||||
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
fastapi==0.111.0
|
||||||
|
uvicorn[standard]==0.29.0
|
||||||
|
asyncpg==0.29.0
|
||||||
|
httpx==0.27.0
|
||||||
|
pydantic==2.7.0
|
||||||
|
pydantic-settings==2.2.1
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
pgvector==0.3.0
|
||||||
|
tenacity==8.3.0
|
||||||
|
pypdf==4.2.0
|
||||||
|
python-docx==1.1.0
|
||||||
48
scripts/init.sql
Normal file
48
scripts/init.sql
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vector_stores (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
owner_user_id VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
store_id UUID REFERENCES vector_stores(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
embedding vector(1024),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS store_permissions (
|
||||||
|
store_id UUID REFERENCES vector_stores(id) ON DELETE CASCADE,
|
||||||
|
user_id VARCHAR(255) NOT NULL,
|
||||||
|
permission VARCHAR(50) DEFAULT 'read',
|
||||||
|
PRIMARY KEY (store_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS usage_stats (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id VARCHAR(255) NOT NULL,
|
||||||
|
store_id UUID REFERENCES vector_stores(id) ON DELETE SET NULL,
|
||||||
|
action VARCHAR(50) NOT NULL,
|
||||||
|
tokens INT DEFAULT 0,
|
||||||
|
duration FLOAT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_store
|
||||||
|
ON documents(store_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_embedding
|
||||||
|
ON documents USING ivfflat (embedding vector_cosine_ops)
|
||||||
|
WITH (lists = 100);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_user
|
||||||
|
ON usage_stats(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_created
|
||||||
|
ON usage_stats(created_at);
|
||||||
|
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO vecuser;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO vecuser;
|
||||||
21
ui/Dockerfile
Normal file
21
ui/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /build
|
||||||
|
COPY package.json .
|
||||||
|
RUN npm install
|
||||||
|
COPY index.html .
|
||||||
|
COPY vite.config.ts .
|
||||||
|
COPY tsconfig.json .
|
||||||
|
COPY tailwind.config.js .
|
||||||
|
COPY postcss.config.js .
|
||||||
|
COPY src/ ./src/
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runtime
|
||||||
|
RUN npm install -g serve
|
||||||
|
COPY --from=builder /build/dist /app
|
||||||
|
|
||||||
|
COPY docker-entrypoint.sh /
|
||||||
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
11
ui/docker-entrypoint.sh
Normal file
11
ui/docker-entrypoint.sh
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
cat > /app/config.js << EOF
|
||||||
|
window.__RUNTIME_CONFIG__ = {
|
||||||
|
API_URL: "${API_URL:-http://localhost:8000}"
|
||||||
|
};
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "✅ Config generiert: API_URL=${API_URL}"
|
||||||
|
|
||||||
|
exec serve -s /app -l 80
|
||||||
13
ui/index.html
Normal file
13
ui/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vector Store Admin</title>
|
||||||
|
<script src="/config.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
ui/package.json
Normal file
28
ui/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "vector-store-admin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.22.0",
|
||||||
|
"@tanstack/react-query": "^5.28.0",
|
||||||
|
"axios": "^1.6.7",
|
||||||
|
"lucide-react": "^0.368.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"autoprefixer": "^10.4.0",
|
||||||
|
"postcss": "^8.4.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"vite": "^5.2.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
ui/postcss.config.js
Normal file
6
ui/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
35
ui/src/App.tsx
Normal file
35
ui/src/App.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import Login from "./pages/Login";
|
||||||
|
import Dashboard from "./pages/Dashboard";
|
||||||
|
import Users from "./pages/Users";
|
||||||
|
import Stores from "./pages/Stores";
|
||||||
|
import Layout from "./components/Layout";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const token = localStorage.getItem("admin_token");
|
||||||
|
return token ? <>{children}</> : <Navigate to="/login" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/" element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<Layout />
|
||||||
|
</PrivateRoute>
|
||||||
|
}>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="users" element={<Users />} />
|
||||||
|
<Route path="stores" element={<Stores />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
ui/src/api/client.ts
Normal file
46
ui/src/api/client.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
// Laufzeit-Config hat Vorrang vor Build-Zeit
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__RUNTIME_CONFIG__?: {
|
||||||
|
API_URL: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_URL =
|
||||||
|
window.__RUNTIME_CONFIG__?.API_URL ?? // ← Laufzeit (K8s ConfigMap)
|
||||||
|
import.meta.env.VITE_API_URL ?? // ← Build-Zeit (Fallback)
|
||||||
|
"http://localhost:8000"; // ← Dev Fallback
|
||||||
|
|
||||||
|
const api = axios.create({ baseURL: API_URL });
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem("admin_token");
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
getStats: () =>
|
||||||
|
api.get("/admin/stats").then((r) => r.data),
|
||||||
|
getUsers: () =>
|
||||||
|
api.get("/admin/users").then((r) => r.data),
|
||||||
|
getUserStores: (userId: string) =>
|
||||||
|
api.get(`/admin/users/${userId}/stores`).then((r) => r.data),
|
||||||
|
deleteStore: (userId: string, storeId: string) =>
|
||||||
|
api.delete(`/admin/users/${userId}/stores/${storeId}`),
|
||||||
|
rotateKey: (userId: string) =>
|
||||||
|
api.post(`/admin/users/${userId}/rotate-key`).then((r) => r.data),
|
||||||
|
getPermissions: (storeId: string) =>
|
||||||
|
api.get(`/admin/stores/${storeId}/permissions`).then((r) => r.data),
|
||||||
|
grantPermission: (storeId: string, userId: string, permission: string) =>
|
||||||
|
api.post(`/admin/stores/${storeId}/permissions`, null, {
|
||||||
|
params: { user_id: userId, permission },
|
||||||
|
}),
|
||||||
|
revokePermission: (storeId: string, userId: string) =>
|
||||||
|
api.delete(`/admin/stores/${storeId}/permissions/${userId}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api;
|
||||||
61
ui/src/components/Layout.tsx
Normal file
61
ui/src/components/Layout.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Outlet, NavLink, useNavigate } from "react-router-dom";
|
||||||
|
import { LayoutDashboard, Users, Database, LogOut } from "lucide-react";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem("admin_token");
|
||||||
|
navigate("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||||
|
{ to: "/users", label: "Benutzer", icon: Users },
|
||||||
|
{ to: "/stores", label: "Stores", icon: Database },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-gray-100">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-56 bg-white shadow-md flex flex-col">
|
||||||
|
<div className="p-5 border-b">
|
||||||
|
<h1 className="font-bold text-lg">🗄️ Vector Admin</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 p-3 space-y-1">
|
||||||
|
{navItems.map(({ to, label, icon: Icon }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
end={to === "/"}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition
|
||||||
|
${isActive
|
||||||
|
? "bg-blue-50 text-blue-700 font-medium"
|
||||||
|
: "text-gray-600 hover:bg-gray-50"}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="flex items-center gap-3 px-6 py-4 text-sm
|
||||||
|
text-gray-500 hover:text-red-600 border-t transition"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="flex-1 overflow-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
ui/src/components/PermissionModal.tsx
Normal file
152
ui/src/components/PermissionModal.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { adminApi } from "../api/client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { X, Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PermissionModal({ userId, onClose }: Props) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [newUser, setNewUser] = useState("");
|
||||||
|
const [newPerm, setNewPerm] = useState("read");
|
||||||
|
const [activeStore, setActiveStore] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: stores = [] } = useQuery({
|
||||||
|
queryKey: ["user-stores", userId],
|
||||||
|
queryFn: () => adminApi.getUserStores(userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: perms = [] } = useQuery({
|
||||||
|
queryKey: ["perms", activeStore],
|
||||||
|
queryFn: () => adminApi.getPermissions(activeStore!),
|
||||||
|
enabled: !!activeStore,
|
||||||
|
});
|
||||||
|
|
||||||
|
const grantMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
adminApi.grantPermission(activeStore!, newUser, newPerm),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["perms", activeStore] });
|
||||||
|
setNewUser("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const revokeMutation = useMutation({
|
||||||
|
mutationFn: (uid: string) =>
|
||||||
|
adminApi.revokePermission(activeStore!, uid),
|
||||||
|
onSuccess: () =>
|
||||||
|
qc.invalidateQueries({ queryKey: ["perms", activeStore] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl w-[600px] max-h-[80vh] overflow-y-auto">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-5 border-b">
|
||||||
|
<h2 className="font-bold text-lg">
|
||||||
|
Berechtigungen — {userId}
|
||||||
|
</h2>
|
||||||
|
<button onClick={onClose}>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 grid grid-cols-2 gap-4">
|
||||||
|
|
||||||
|
{/* Stores-Liste */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-2 text-sm text-gray-600">
|
||||||
|
STORES
|
||||||
|
</h3>
|
||||||
|
{stores.map((s: any) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => setActiveStore(s.id)}
|
||||||
|
className={`w-full text-left p-3 rounded-lg mb-1 text-sm border transition
|
||||||
|
${activeStore === s.id
|
||||||
|
? "bg-blue-50 border-blue-400"
|
||||||
|
: "hover:bg-gray-50 border-transparent"}`}
|
||||||
|
>
|
||||||
|
<p className="font-medium">{s.name}</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{s.document_count} Dokumente
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permissions */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-2 text-sm text-gray-600">
|
||||||
|
ZUGRIFF GEWÄHREN
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{!activeStore ? (
|
||||||
|
<p className="text-sm text-gray-400">Store auswählen →</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Bestehende Berechtigungen */}
|
||||||
|
<div className="mb-3 space-y-1">
|
||||||
|
{perms.map((p: any) => (
|
||||||
|
<div
|
||||||
|
key={p.user_id}
|
||||||
|
className="flex items-center justify-between bg-gray-50 p-2 rounded text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-xs">{p.user_id}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium
|
||||||
|
${p.permission === "admin" ? "bg-red-100 text-red-700" : ""}
|
||||||
|
${p.permission === "write" ? "bg-yellow-100 text-yellow-700" : ""}
|
||||||
|
${p.permission === "read" ? "bg-green-100 text-green-700" : ""}`}>
|
||||||
|
{p.permission}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => revokeMutation.mutate(p.user_id)}
|
||||||
|
className="text-red-400 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Neue Berechtigung */}
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<input
|
||||||
|
placeholder="user_id"
|
||||||
|
value={newUser}
|
||||||
|
onChange={(e) => setNewUser(e.target.value)}
|
||||||
|
className="w-full border rounded p-2 text-sm mb-2"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={newPerm}
|
||||||
|
onChange={(e) => setNewPerm(e.target.value)}
|
||||||
|
className="w-full border rounded p-2 text-sm mb-2"
|
||||||
|
>
|
||||||
|
<option value="read">read</option>
|
||||||
|
<option value="write">write</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => grantMutation.mutate()}
|
||||||
|
disabled={!newUser}
|
||||||
|
className="w-full bg-blue-600 text-white py-2 rounded text-sm
|
||||||
|
hover:bg-blue-700 disabled:opacity-50 flex items-center
|
||||||
|
justify-center gap-1"
|
||||||
|
>
|
||||||
|
<Plus size={14} /> Zugriff gewähren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
ui/src/index.css
Normal file
3
ui/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
10
ui/src/main.tsx
Normal file
10
ui/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
39
ui/src/pages/Dashboard.tsx
Normal file
39
ui/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { adminApi } from "../api/client";
|
||||||
|
import { Database, Users, FileText, Key } from "lucide-react";
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { data: stats, isLoading } = useQuery({
|
||||||
|
queryKey: ["stats"],
|
||||||
|
queryFn: adminApi.getStats,
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <p className="p-6">Lade...</p>;
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{ label: "Gesamt Stores", value: stats?.total_stores, icon: Database, color: "blue" },
|
||||||
|
{ label: "Gesamt User", value: stats?.total_users, icon: Users, color: "green" },
|
||||||
|
{ label: "Gesamt Dokumente", value: stats?.total_documents, icon: FileText, color: "purple" },
|
||||||
|
{ label: "Berechtigungen", value: stats?.total_permissions, icon: Key, color: "orange" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
||||||
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
{cards.map(({ label, value, icon: Icon, color }) => (
|
||||||
|
<div key={label} className="bg-white rounded-xl shadow p-5 flex items-center gap-4">
|
||||||
|
<div className={`p-3 rounded-full bg-${color}-100`}>
|
||||||
|
<Icon className={`text-${color}-600`} size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">{label}</p>
|
||||||
|
<p className="text-2xl font-bold">{value ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
ui/src/pages/Login.tsx
Normal file
117
ui/src/pages/Login.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [key, setKey] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!key.trim()) {
|
||||||
|
setError("Bitte API Key eingeben");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/admin/verify", {
|
||||||
|
headers: { Authorization: `Bearer ${key}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
localStorage.setItem("admin_token", key);
|
||||||
|
navigate("/");
|
||||||
|
|
||||||
|
} else if (resp.status === 401) {
|
||||||
|
setError("Ungültiger API Key");
|
||||||
|
|
||||||
|
} else if (resp.status === 403) {
|
||||||
|
setError("Keine Admin-Berechtigung für diesen Key");
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
setError(data?.detail || `Fehler ${resp.status}: Anmeldung fehlgeschlagen`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Verbindungsfehler: ${e}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||||
|
<div className="bg-white p-8 rounded-xl shadow-md w-96">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="text-5xl mb-4">🗄️</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800">
|
||||||
|
Vector Store Admin
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 text-sm mt-1">
|
||||||
|
Bitte mit Admin API Key anmelden
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
API Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="sk-..."
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => {
|
||||||
|
setKey(e.target.value);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && !loading && handleLogin()}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full border rounded-lg p-3
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||||
|
disabled:opacity-50 disabled:bg-gray-50
|
||||||
|
transition"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fehler */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-300 text-red-700
|
||||||
|
rounded-lg p-3 text-sm mb-4 flex items-start gap-2">
|
||||||
|
<span className="mt-0.5">❌</span>
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleLogin}
|
||||||
|
disabled={loading || !key.trim()}
|
||||||
|
className="w-full bg-blue-600 text-white py-3 rounded-lg
|
||||||
|
hover:bg-blue-700 transition font-medium
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4
|
||||||
|
border-b-2 border-white" />
|
||||||
|
Anmelden...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
🔐 Anmelden
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
235
ui/src/pages/Stores.tsx
Normal file
235
ui/src/pages/Stores.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { adminApi } from "../api/client";
|
||||||
|
import PermissionModal from "../components/PermissionModal";
|
||||||
|
import { Trash2, Shield, ChevronDown, ChevronUp, FileText } from "lucide-react";
|
||||||
|
|
||||||
|
interface Store {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
owner_user_id: string;
|
||||||
|
document_count: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
user_id: string;
|
||||||
|
store_count: number;
|
||||||
|
last_activity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Stores() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [expandedUser, setExpandedUser] = useState<string | null>(null);
|
||||||
|
const [selectedStore, setStore] = useState<string | null>(null);
|
||||||
|
const [confirmDelete, setConfirm] = useState<{
|
||||||
|
storeId: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const { data: users = [], isLoading } = useQuery({
|
||||||
|
queryKey: ["users"],
|
||||||
|
queryFn: adminApi.getUsers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: stores = [], isLoading: storesLoading } = useQuery({
|
||||||
|
queryKey: ["user-stores", expandedUser],
|
||||||
|
queryFn: () => adminApi.getUserStores(expandedUser!),
|
||||||
|
enabled: !!expandedUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: ({ userId, storeId }: { userId: string; storeId: string }) =>
|
||||||
|
adminApi.deleteStore(userId, storeId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["user-stores", expandedUser] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
setConfirm(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleUser = (userId: string) =>
|
||||||
|
setExpandedUser(expandedUser === userId ? null : userId);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 flex items-center gap-2 text-gray-500">
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600" />
|
||||||
|
Lade Stores...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Store-Verwaltung</h1>
|
||||||
|
|
||||||
|
{/* Lösch-Bestätigung */}
|
||||||
|
{confirmDelete && (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl p-6 w-96">
|
||||||
|
<h2 className="font-bold text-lg mb-2">Store löschen?</h2>
|
||||||
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
|
Store{" "}
|
||||||
|
<span className="font-mono font-semibold">
|
||||||
|
{confirmDelete.name}
|
||||||
|
</span>{" "}
|
||||||
|
wird unwiderruflich gelöscht — inkl. aller Dokumente.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirm(null)}
|
||||||
|
className="px-4 py-2 rounded-lg border text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
deleteMutation.mutate({
|
||||||
|
userId: confirmDelete.userId,
|
||||||
|
storeId: confirmDelete.storeId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="px-4 py-2 rounded-lg bg-red-600 text-white text-sm
|
||||||
|
hover:bg-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? "Lösche..." : "Löschen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User-Gruppen */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{users.length === 0 && (
|
||||||
|
<p className="text-gray-400 text-sm">Keine Stores vorhanden.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{users.map((user: User) => (
|
||||||
|
<div
|
||||||
|
key={user.user_id}
|
||||||
|
className="bg-white rounded-xl shadow overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* User-Header */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleUser(user.user_id)}
|
||||||
|
className="w-full flex items-center justify-between p-4
|
||||||
|
hover:bg-gray-50 transition text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center
|
||||||
|
justify-center text-blue-700 font-bold text-sm">
|
||||||
|
{user.user_id.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm font-mono">
|
||||||
|
{user.user_id}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{user.store_count} Store
|
||||||
|
{user.store_count !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-gray-400">
|
||||||
|
<span className="text-xs">
|
||||||
|
{new Date(user.last_activity).toLocaleDateString("de-DE")}
|
||||||
|
</span>
|
||||||
|
{expandedUser === user.user_id
|
||||||
|
? <ChevronUp size={18} />
|
||||||
|
: <ChevronDown size={18} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Store-Liste */}
|
||||||
|
{expandedUser === user.user_id && (
|
||||||
|
<div className="border-t">
|
||||||
|
{storesLoading ? (
|
||||||
|
<div className="p-4 flex items-center gap-2 text-gray-400 text-sm">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4
|
||||||
|
border-b-2 border-blue-600" />
|
||||||
|
Lade Stores...
|
||||||
|
</div>
|
||||||
|
) : stores.length === 0 ? (
|
||||||
|
<p className="p-4 text-sm text-gray-400">
|
||||||
|
Keine Stores gefunden.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 text-xs text-gray-500 uppercase">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-2">Name</th>
|
||||||
|
<th className="text-left px-4 py-2">Dokumente</th>
|
||||||
|
<th className="text-left px-4 py-2">Erstellt</th>
|
||||||
|
<th className="text-left px-4 py-2">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stores.map((store: Store) => (
|
||||||
|
<tr
|
||||||
|
key={store.id}
|
||||||
|
className="border-t hover:bg-gray-50 transition"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-medium">
|
||||||
|
{store.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1 text-gray-500">
|
||||||
|
<FileText size={14} />
|
||||||
|
{store.document_count}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400 text-xs">
|
||||||
|
{new Date(store.created_at).toLocaleString("de-DE")}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setStore(store.id)}
|
||||||
|
title="Berechtigungen verwalten"
|
||||||
|
className="p-1.5 bg-blue-100 text-blue-700
|
||||||
|
rounded hover:bg-blue-200 transition"
|
||||||
|
>
|
||||||
|
<Shield size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setConfirm({
|
||||||
|
storeId: store.id,
|
||||||
|
userId: user.user_id,
|
||||||
|
name: store.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
title="Store löschen"
|
||||||
|
className="p-1.5 bg-red-100 text-red-700
|
||||||
|
rounded hover:bg-red-200 transition"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permission Modal */}
|
||||||
|
{selectedStore && (
|
||||||
|
<PermissionModal
|
||||||
|
userId={expandedUser!}
|
||||||
|
onClose={() => setStore(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
ui/src/pages/Users.tsx
Normal file
92
ui/src/pages/Users.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { adminApi } from "../api/client";
|
||||||
|
import PermissionModal from "../components/PermissionModal";
|
||||||
|
import { RefreshCw, Trash2, Shield } from "lucide-react";
|
||||||
|
|
||||||
|
export default function Users() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [selectedStore, setStore] = useState<string | null>(null);
|
||||||
|
const [newKey, setNewKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: users = [], isLoading } = useQuery({
|
||||||
|
queryKey: ["users"],
|
||||||
|
queryFn: adminApi.getUsers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rotateMutation = useMutation({
|
||||||
|
mutationFn: (userId: string) => adminApi.rotateKey(userId),
|
||||||
|
onSuccess: (data) => setNewKey(data.new_key),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <p className="p-6">Lade User...</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Benutzerverwaltung</h1>
|
||||||
|
|
||||||
|
{/* Neuer Key Dialog */}
|
||||||
|
{newKey && (
|
||||||
|
<div className="mb-4 p-4 bg-green-50 border border-green-300 rounded-lg">
|
||||||
|
<p className="font-semibold text-green-800">✅ Neuer API Key:</p>
|
||||||
|
<code className="block mt-1 text-sm bg-white p-2 rounded border">
|
||||||
|
{newKey}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={() => setNewKey(null)}
|
||||||
|
className="mt-2 text-sm text-green-700 underline"
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-4">User ID</th>
|
||||||
|
<th className="text-left p-4">Stores</th>
|
||||||
|
<th className="text-left p-4">Letzte Aktivität</th>
|
||||||
|
<th className="text-left p-4">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u: any) => (
|
||||||
|
<tr key={u.user_id} className="border-t hover:bg-gray-50">
|
||||||
|
<td className="p-4 font-mono text-xs">{u.user_id}</td>
|
||||||
|
<td className="p-4">{u.store_count}</td>
|
||||||
|
<td className="p-4 text-gray-500">
|
||||||
|
{new Date(u.last_activity).toLocaleString("de-DE")}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setStore(u.user_id)}
|
||||||
|
title="Berechtigungen"
|
||||||
|
className="p-1.5 bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
|
||||||
|
>
|
||||||
|
<Shield size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => rotateMutation.mutate(u.user_id)}
|
||||||
|
title="Key rotieren"
|
||||||
|
className="p-1.5 bg-yellow-100 text-yellow-700 rounded hover:bg-yellow-200"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedStore && (
|
||||||
|
<PermissionModal
|
||||||
|
userId={selectedStore}
|
||||||
|
onClose={() => setStore(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
ui/tailwind.config.js
Normal file
5
ui/tailwind.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
theme: { extend: {} },
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
12
ui/tsconfig.json
Normal file
12
ui/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
10
ui/vite.config.ts
Normal file
10
ui/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
sourcemap: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user