478 lines
17 KiB
Markdown
478 lines
17 KiB
Markdown
# Disseny: Capa de Persistència MiroFish
|
||
|
||
## 1. Context i motivació
|
||
|
||
MiroFish es desplega com a contenidor Docker. Sense volums muntats, tot el que es guarda al filesystem del contenidor (`backend/uploads/`) es perd en cada reinici o redeploy. A més:
|
||
|
||
- `TaskManager` és 100% in-memory: cada reinici invalida les tasques en curs, impedint recovery de l'usuari
|
||
- `ProjectManager` persisteix via JSON a disc: funcional en dev però no en contenidors efímers ni en multi-instància
|
||
- No hi ha gestió d'usuaris: tots els projectes són globals i sense propietari
|
||
- No hi ha configuració dinàmica: tots els paràmetres d'LLM, límits i features van hard-coded a variables d'entorn
|
||
|
||
Aquest disseny estableix la capa de persistència completa que farà l'aplicació viable en producció.
|
||
|
||
---
|
||
|
||
## 2. Principis de disseny
|
||
|
||
1. **Abstracció total del backend** — les capes de negoci mai toquen SQL ni fitxers directament; ho fan a través de `StorageService` i sessions SQLAlchemy
|
||
2. **Un URL, dos entorns** — `DATABASE_URL` i `STORAGE_TYPE` seleccionen el backend; el codi de negoci no canvia
|
||
3. **Paths estables** — els `storage_path` guardats a la BD no depenen del backend de storage; l'adaptador els resol
|
||
4. **Dades de simulació OASIS separades** — els fitxers `.db` SQLite del motor OASIS es tracten com a artefactes i es guarden a storage, no a la BD de l'app
|
||
5. **Configuració dinàmica per admins** — paràmetres modificables en runtime van a `SystemConfig` a la BD, no a variables d'entorn
|
||
|
||
---
|
||
|
||
## 3. Arquitectura general
|
||
|
||
```
|
||
┌─────────────────────────────────────────┐
|
||
│ Flask Routes │
|
||
├─────────────────────────────────────────┤
|
||
│ Services (ProjectService, AuthService, │
|
||
│ SimulationService, ReportService...) │
|
||
│ → usen StorageService + DB Session │
|
||
├──────────────────┬──────────────────────┤
|
||
│ StorageService │ SQLAlchemy 2.x │
|
||
│ (Protocol) │ Session Factory │
|
||
├────────┬─────────┼──────────────────────┤
|
||
│LocalFS │AzureBlob│ SQLite (dev) │
|
||
│ │ (S3*) │ PostgreSQL (prod) │
|
||
└────────┴─────────┴──────────────────────┘
|
||
|
||
* S3: interfície dissenyada, implementació diferida
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Model de dades (SQLAlchemy 2.x)
|
||
|
||
### User
|
||
|
||
| camp | tipus | notes |
|
||
|------|-------|-------|
|
||
| id | UUID PK | |
|
||
| email | text unique | |
|
||
| name | text | |
|
||
| password_hash | text | bcrypt |
|
||
| role | enum: user\|admin | |
|
||
| status | enum: pending\|active\|disabled | |
|
||
| created_at | datetime | |
|
||
| updated_at | datetime | |
|
||
|
||
### Project
|
||
|
||
| camp | tipus | notes |
|
||
|------|-------|-------|
|
||
| id | UUID PK | |
|
||
| user_id | FK → User | CASCADE delete |
|
||
| name | text | |
|
||
| status | text | created\|ontology_generated\|graph_building\|graph_completed\|failed |
|
||
| analysis_summary | text nullable | resum LLM del document |
|
||
| simulation_requirement | text nullable | pregunta de simulació |
|
||
| chunk_size | int | default 500 |
|
||
| chunk_overlap | int | default 50 |
|
||
| active_task_id | FK → Task nullable | ON DELETE SET NULL |
|
||
| created_at | datetime | |
|
||
| updated_at | datetime | |
|
||
|
||
### ProjectFile
|
||
|
||
| camp | tipus | notes |
|
||
|------|-------|-------|
|
||
| id | UUID PK | |
|
||
| project_id | FK → Project | CASCADE delete |
|
||
| original_name | text | nom original del fitxer |
|
||
| storage_path | text | path relatiu a StorageService |
|
||
| size | int | bytes |
|
||
| mime_type | text | |
|
||
| file_type | enum: upload\|extracted_text | |
|
||
| created_at | datetime | |
|
||
|
||
### Ontology
|
||
|
||
| camp | tipus | notes |
|
||
|------|-------|-------|
|
||
| id | UUID PK | |
|
||
| project_id | FK → Project | CASCADE delete |
|
||
| version | int | default 1, incrementa en re-generació |
|
||
| entity_types | JSON | llista de {name, description, attributes, examples} |
|
||
| edge_types | JSON | llista de {name, source_targets, attributes} |
|
||
| created_at | datetime | |
|
||
|
||
### Graph
|
||
|
||
| camp | tipus | notes |
|
||
|------|-------|-------|
|
||
| id | UUID PK | |
|
||
| project_id | FK → Project | CASCADE delete |
|
||
| ontology_id | FK → Ontology | quin esquema va usar |
|
||
| backend | enum: zep\|graphiti | |
|
||
| external_id | text | ID a Zep Cloud o nom del graf Neo4j |
|
||
| status | enum: building\|ready\|failed | |
|
||
| node_count | int nullable | stats post-build |
|
||
| edge_count | int nullable | stats post-build |
|
||
| created_at | datetime | |
|
||
| updated_at | datetime | |
|
||
|
||
### Simulation
|
||
|
||
| camp | tipus | notes |
|
||
|------|-------|-------|
|
||
| id | UUID PK | |
|
||
| project_id | FK → Project | CASCADE delete |
|
||
| graph_id | FK → Graph | quin graf usa |
|
||
| status | text | prepared\|running\|completed\|failed |
|
||
| platform | enum: twitter\|reddit\|both | |
|
||
| config | JSON | time steps, events, agent params |
|
||
| profiles_path | text nullable | storage_path al JSON/CSV perfils |
|
||
| db_path | text nullable | storage_path al fitxer .db OASIS |
|
||
| actions_path | text nullable | storage_path al JSONL accions |
|
||
| rounds_total | int nullable | |
|
||
| rounds_completed | int | default 0 |
|
||
| created_at | datetime | |
|
||
| updated_at | datetime | |
|
||
|
||
### Report
|
||
|
||
| camp | tipus | notes |
|
||
|------|-------|-------|
|
||
| id | UUID PK | |
|
||
| project_id | FK → Project | CASCADE delete |
|
||
| simulation_id | FK → Simulation | |
|
||
| graph_id | FK → Graph | per fer queries Zep durant generació |
|
||
| status | text | generating\|completed\|failed |
|
||
| outline | JSON nullable | estructura de seccions |
|
||
| storage_prefix | text | prefix del directori a storage |
|
||
| created_at | datetime | |
|
||
| updated_at | datetime | |
|
||
|
||
### Task
|
||
|
||
| camp | tipus | notes |
|
||
|------|-------|-------|
|
||
| id | UUID PK | |
|
||
| task_type | text | ontology_generate\|graph_build\|simulation_prepare\|etc. |
|
||
| entity_type | text | project\|simulation\|report |
|
||
| entity_id | text | UUID de l'entitat relacionada |
|
||
| status | enum: pending\|processing\|completed\|failed | |
|
||
| progress | int | 0–100 |
|
||
| message | text nullable | missatge de progrés |
|
||
| result | JSON nullable | resultat final |
|
||
| error | text nullable | error si failed |
|
||
| created_at | datetime | |
|
||
| updated_at | datetime | |
|
||
|
||
### SystemConfig
|
||
|
||
| camp | tipus | notes |
|
||
|------|-------|-------|
|
||
| key | text PK | p.ex. `llm.api_key` |
|
||
| value | text | valor serialitzat |
|
||
| value_type | enum: string\|int\|float\|bool\|json | per deserialitzar |
|
||
| group | enum: llm\|limits\|email\|graph\|features | agrupació UI |
|
||
| label | text | text llegible |
|
||
| description | text | |
|
||
| is_secret | bool | emmascara el valor a la UI |
|
||
| updated_at | datetime | |
|
||
| updated_by | FK → User nullable | qui va fer el canvi |
|
||
|
||
#### Claus predefinides per grup
|
||
|
||
| group | key | secret |
|
||
|-------|-----|--------|
|
||
| llm | llm.api_key | sí |
|
||
| llm | llm.base_url | no |
|
||
| llm | llm.model_name | no |
|
||
| llm | llm.boost_model_name | no |
|
||
| graph | graph.backend | no |
|
||
| graph | graph.zep_api_key | sí |
|
||
| limits | limits.max_projects_per_user | no |
|
||
| limits | limits.max_file_size_mb | no |
|
||
| limits | limits.max_simulation_agents | no |
|
||
| email | email.sender_address | no |
|
||
| email | email.acs_endpoint | no |
|
||
| email | email.acs_key | sí |
|
||
| features | features.registration_open | no |
|
||
|
||
### InvitationToken
|
||
|
||
| camp | tipus | notes |
|
||
|------|-------|-------|
|
||
| token | text PK | UUID o token segur |
|
||
| user_id | FK → User | CASCADE delete |
|
||
| expires_at | datetime | TTL 48h |
|
||
| used_at | datetime nullable | null = no usat |
|
||
|
||
### PasswordResetToken
|
||
|
||
| camp | tipus | notes |
|
||
|------|-------|-------|
|
||
| token | text PK | |
|
||
| user_id | FK → User | CASCADE delete |
|
||
| expires_at | datetime | TTL 1h |
|
||
| used_at | datetime nullable | |
|
||
|
||
### Relacions clau
|
||
|
||
```
|
||
User ──1:N──► Project ──1:N──► ProjectFile
|
||
──1:N──► Ontology ──1:N──► Graph ──1:N──► Simulation ──1:N──► Report
|
||
──1:N──► Task (via entity_id)
|
||
```
|
||
|
||
- `Project.active_task_id` → FK → Task amb `ON DELETE SET NULL`
|
||
- Eliminació User → CASCADE elimina tots els seus Projects i descendents
|
||
- Eliminació Project → CASCADE elimina Files, Ontologies, Graphs, Simulations, Reports
|
||
|
||
---
|
||
|
||
## 5. StorageService (abstracció de fitxers)
|
||
|
||
### Interfície Protocol (Python)
|
||
|
||
```python
|
||
from typing import IO, Protocol
|
||
|
||
class StorageService(Protocol):
|
||
def upload(self, path: str, data: bytes | IO, content_type: str) -> None: ...
|
||
def download(self, path: str) -> bytes: ...
|
||
def download_stream(self, path: str) -> IO: ...
|
||
def delete(self, path: str) -> None: ...
|
||
def delete_prefix(self, prefix: str) -> None: ...
|
||
def exists(self, path: str) -> bool: ...
|
||
def list(self, prefix: str) -> list[str]: ...
|
||
def public_url(self, path: str) -> str | None: ...
|
||
```
|
||
|
||
### Implementacions
|
||
|
||
| `STORAGE_TYPE` | Classe | Dependència |
|
||
|---|---|---|
|
||
| `local` | `LocalFSStorage` | stdlib `pathlib` |
|
||
| `azure` | `AzureBlobStorage` | `azure-storage-blob` |
|
||
| `s3` *(disseny llest, no implementat)* | `S3Storage` | `boto3` |
|
||
|
||
La selecció es fa a l'inici de l'app via `STORAGE_TYPE`:
|
||
|
||
```python
|
||
def create_storage_service() -> StorageService:
|
||
match os.environ.get("STORAGE_TYPE", "local"):
|
||
case "azure": return AzureBlobStorage(...)
|
||
case "s3": return S3Storage(...) # futur
|
||
case _: return LocalFSStorage(...)
|
||
```
|
||
|
||
### Convenció de paths
|
||
|
||
Els paths guardats a la BD són relatius i independents del backend:
|
||
|
||
```
|
||
projects/{project_id}/files/{filename}
|
||
projects/{project_id}/extracted_text.txt
|
||
simulations/{sim_id}/{platform}_simulation.db
|
||
simulations/{sim_id}/profiles.json
|
||
simulations/{sim_id}/twitter_profiles.csv
|
||
simulations/{sim_id}/actions.jsonl
|
||
reports/{report_id}/full_report.md
|
||
reports/{report_id}/section_{nn}.md
|
||
reports/{report_id}/agent_log.jsonl
|
||
reports/{report_id}/console_log.txt
|
||
```
|
||
|
||
En canviar de `local` a `azure` (o `s3`), els paths a la BD no canvien — només l'adaptador que els resol.
|
||
|
||
---
|
||
|
||
## 6. Autenticació i RBAC
|
||
|
||
### Mecanisme
|
||
|
||
- **JWT** via `flask-jwt-extended`
|
||
- Access token: 8h, en cookie HTTP-only
|
||
- Refresh token: 7 dies, en cookie HTTP-only
|
||
- Cap token en `localStorage` (evita XSS)
|
||
|
||
### Rols i accés
|
||
|
||
Un `admin` **és també un `user`**: té els seus propis projectes i usa l'aplicació igual que qualsevol usuari. Les capacitats addicionals d'administració (veure tots els projectes, gestió d'usuaris, `SystemConfig`) estan en pantalles separades, accessibles des d'un menú d'administració.
|
||
|
||
| Rol | Accés aplicació | Accés administració |
|
||
|-----|-----------------|---------------------|
|
||
| `user` | Els seus propis projectes | — |
|
||
| `admin` | Els seus propis projectes | Tots els projectes + Gestió d'usuaris + SystemConfig |
|
||
|
||
Decoradors Flask:
|
||
|
||
```python
|
||
@require_auth # qualsevol usuari autenticat
|
||
@require_admin # només admins
|
||
@require_project_owner # propietari del projecte o admin
|
||
```
|
||
|
||
### Auth provisional a eliminar
|
||
|
||
Actualment existeix un sistema d'autenticació provisional (`backend/app/api/auth.py`) basat en un usuari únic `demo` amb `DEMO_PASSWORD` com a variable d'entorn i JWT HS256 de 24h. **Cal eliminar-lo completament** i substituir-lo pel sistema descrit en aquesta especificació.
|
||
|
||
### Flux d'invitació
|
||
|
||
```
|
||
Admin crea usuari (status: pending)
|
||
→ email amb InvitationToken (TTL 48h)
|
||
→ usuari clica l'enllaç, defineix contrasenya
|
||
→ status: active
|
||
```
|
||
|
||
### Flux "He oblidat la contrasenya"
|
||
|
||
```
|
||
Usuari introdueix el seu email al formulari
|
||
→ resposta sempre idèntica: "Si l'adreça existeix, rebràs un correu en breus"
|
||
(no revelar si l'email és vàlid o no)
|
||
→ si l'email existeix a la BD: enviar PasswordResetToken (TTL 1h)
|
||
→ usuari clica l'enllaç, defineix nova contrasenya
|
||
→ token marcat com a used_at
|
||
```
|
||
|
||
**Principi de seguretat:** cap resposta de l'API d'autenticació ha de revelar si un email existeix o no al sistema. Tant el login fallit com el forgot-password han de retornar missatges genèrics.
|
||
|
||
### Endpoints d'autenticació
|
||
|
||
```
|
||
POST /api/auth/login → no revelar si email/password és incorrecte per separat
|
||
POST /api/auth/refresh
|
||
POST /api/auth/logout
|
||
POST /api/auth/set-password (invitació + primer login)
|
||
POST /api/auth/forgot-password → resposta genèrica sempre
|
||
POST /api/auth/reset-password
|
||
```
|
||
|
||
### Look & feel
|
||
|
||
La interfície d'autenticació i administració seguirà el **Sistema de Disseny de la Generalitat de Catalunya (Gencat)**, consistent amb el disseny existent a `dev/AgentCAT`. Principis aplicables:
|
||
- Accessibilitat per defecte (teclat, focus visible, labels explícits)
|
||
- Inputs amb ajuda contextual i missatges d'error associats (però sense revelar info sensible)
|
||
- Estados completament modelats: loading, error, confirmació
|
||
- Patrons previsibles i feedback immediat
|
||
|
||
---
|
||
|
||
## 7. SystemConfig: resolució de valors
|
||
|
||
Prioritat descendent (el primer que es trobi guanya):
|
||
|
||
1. **Variable d'entorn** — sobreescriu tot; útil per secrets en CI/CD
|
||
2. **`SystemConfig` a la BD** — configurable per admins via UI en runtime
|
||
3. **Valor per defecte al codi** — fallback si no hi ha cap dels anteriors
|
||
|
||
```python
|
||
def get_config(key: str, default=None):
|
||
env_key = key.replace(".", "_").upper() # llm.api_key → LLM_API_KEY
|
||
if val := os.environ.get(env_key):
|
||
return val
|
||
if row := db.query(SystemConfig).get(key):
|
||
return row.typed_value
|
||
return default
|
||
```
|
||
|
||
---
|
||
|
||
## 8. Variables d'entorn (infraestructura)
|
||
|
||
Les variables d'entorn cobreixen exclusivament la infraestructura (no modificables en runtime):
|
||
|
||
```env
|
||
# Base de dades
|
||
DATABASE_URL=sqlite:///dev.db
|
||
# DATABASE_URL=postgresql://user:pass@host:5432/mirofish
|
||
|
||
# Storage
|
||
STORAGE_TYPE=local
|
||
STORAGE_LOCAL_PATH=./uploads
|
||
# STORAGE_TYPE=azure
|
||
AZURE_STORAGE_CONNECTION_STRING=...
|
||
AZURE_STORAGE_CONTAINER=mirofish
|
||
|
||
# Autenticació
|
||
JWT_SECRET=...
|
||
JWT_REFRESH_SECRET=...
|
||
|
||
# Entorn
|
||
ENVIRONMENT=development
|
||
# ENVIRONMENT=production
|
||
```
|
||
|
||
Paràmetres com `LLM_API_KEY`, `LLM_MODEL_NAME`, `ZEP_API_KEY`, límits i features van a `SystemConfig` (amb fallback a variable d'entorn per compatibilitat).
|
||
|
||
---
|
||
|
||
## 9. Fases d'implementació
|
||
|
||
### Fase 1 — Infraestructura base
|
||
|
||
**Objectiu:** BD + storage funcionals, `ProjectManager` i `TaskManager` migrats.
|
||
|
||
- Configurar SQLAlchemy 2.x + Alembic (migració inicial)
|
||
- Crear tots els models SQLAlchemy
|
||
- Implementar `StorageService` + `LocalFSStorage` + `AzureBlobStorage`
|
||
- Migrar `ProjectManager` (JSON → BD)
|
||
- Migrar `TaskManager` (memòria → BD)
|
||
- Injectar `StorageService` i sessió DB a Flask via app factory
|
||
|
||
**Verificació:** crear projecte, pujar fitxer, crear task → reiniciar servidor → verificar que tot sobreviu
|
||
|
||
---
|
||
|
||
### Fase 2 — Autenticació i multi-usuari
|
||
|
||
**Objectiu:** login, RBAC i aïllament de dades per usuari.
|
||
|
||
- Models `User`, `InvitationToken`, `PasswordResetToken`
|
||
- JWT via `flask-jwt-extended` (cookies HTTP-only)
|
||
- Decoradors `@require_auth`, `@require_admin`, `@require_project_owner`
|
||
- Flux invitació + reset contrasenya
|
||
- Totes les rutes de `Project` filtrades per `user_id`
|
||
|
||
**Verificació:** login, accés projecte propi, bloqueig projecte d'altri, flux invitació complet
|
||
|
||
---
|
||
|
||
### Fase 3 — Migració pipeline complet
|
||
|
||
**Objectiu:** les 5 etapes del pipeline usen BD + storage.
|
||
|
||
- `GraphBuilder` → desa `Ontology` + `Graph` a BD
|
||
- `SimulationRunner` → desa `.db` OASIS a `StorageService`, metadades a `Simulation`
|
||
- `ReportAgent` → desa seccions a `StorageService`, metadades a `Report`
|
||
- `SystemConfig` operatiu (LLM keys, Zep key, límits via BD)
|
||
|
||
**Verificació:** pipeline end-to-end (upload → ontologia → graf → simulació → report) amb BD + storage
|
||
|
||
---
|
||
|
||
### Fase 4 — Hardening producció
|
||
|
||
**Objectiu:** desplegament estable en contenidor amb PostgreSQL + Azure Blob.
|
||
|
||
- Configurar PostgreSQL + connection pool (SQLAlchemy `pool_size`, `max_overflow`)
|
||
- Configurar Azure Blob Storage
|
||
- `.env.dev` i `.env.prod` per entorn
|
||
- Health check endpoint (`GET /api/health`) que verifica BD + storage
|
||
- Smoke tests en contenidor Docker
|
||
|
||
**Verificació:** `docker build` + `docker run` sense volums → pipeline complet → reinici contenidor → dades persistides
|
||
|
||
---
|
||
|
||
## 10. Verificació end-to-end
|
||
|
||
```
|
||
1. Arrancar en mode dev (SQLite + LocalFS)
|
||
2. Crear projecte → verificar fila a BD + fitxer a uploads/
|
||
3. Executar pipeline complet → verificar Ontology, Graph, Simulation, Report a BD
|
||
4. Reiniciar servidor → verificar que projecte, tasks i fitxers sobreviuen
|
||
5. Canviar STORAGE_TYPE=azure → verificar que paths a BD no canvien
|
||
6. Canviar DATABASE_URL=postgresql://... + alembic upgrade head → verificar sense errors
|
||
7. Reiniciar contenidor sense volum → verificar dades persistides a BD + Azure Blob
|
||
```
|