From 36107c2c6906ead0d5c0b61cea8122e6f57337d5 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 26 Apr 2026 17:03:47 +0000 Subject: [PATCH] =?UTF-8?q?docs(persistencia):=20afegir=20especificaci?= =?UTF-8?q?=C3=B3=20completa=20de=20la=20capa=20de=20persist=C3=A8ncia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disseny validat durant sessió de brainstorming. Cobreix: - Model de dades SQLAlchemy 2.x (User, Project, Ontology, Graph, Simulation, Report, Task, SystemConfig) - StorageService amb adaptadors LocalFS / Azure Blob (S3 preparat però diferit) - Autenticació JWT + RBAC (user/admin) amb flux d'invitació - SystemConfig per a configuració dinàmica (LLM keys, límits, features) - 4 fases d'implementació amb criteris de verificació Co-Authored-By: Claude Sonnet 4.6 --- .../specs/2026-04-26-persistencia-design.md | 450 ++++++++++++++++++ 1 file changed, 450 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-26-persistencia-design.md diff --git a/docs/superpowers/specs/2026-04-26-persistencia-design.md b/docs/superpowers/specs/2026-04-26-persistencia-design.md new file mode 100644 index 00000000..2c28c378 --- /dev/null +++ b/docs/superpowers/specs/2026-04-26-persistencia-design.md @@ -0,0 +1,450 @@ +# 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 + +| Rol | Accés | +|-----|-------| +| `user` | Veu i gestiona els seus propis projectes | +| `admin` | Veu 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 +``` + +### Flux d'invitació + +``` +Admin crea usuari (status: pending) + → email amb InvitationToken (TTL 48h) + → usuari clica l'enllaç, defineix contrasenya + → status: active +``` + +### Endpoints d'autenticació + +``` +POST /api/auth/login +POST /api/auth/refresh +POST /api/auth/logout +POST /api/auth/set-password (invitació + primer login) +POST /api/auth/forgot-password +POST /api/auth/reset-password +``` + +--- + +## 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 +```