MicroFish/docs/superpowers/specs/2026-04-26-persistencia-des...

478 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 | 0100 |
| 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
```