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