17 KiB
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'usuariProjectManagerpersisteix 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
- Abstracció total del backend — les capes de negoci mai toquen SQL ni fitxers directament; ho fan a través de
StorageServicei sessions SQLAlchemy - Un URL, dos entorns —
DATABASE_URLiSTORAGE_TYPEseleccionen el backend; el codi de negoci no canvia - Paths estables — els
storage_pathguardats a la BD no depenen del backend de storage; l'adaptador els resol - Dades de simulació OASIS separades — els fitxers
.dbSQLite del motor OASIS es tracten com a artefactes i es guarden a storage, no a la BD de l'app - Configuració dinàmica per admins — paràmetres modificables en runtime van a
SystemConfiga 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 | |
| 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.sender_address | no | |
| email.acs_endpoint | no | |
| 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 ambON 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)
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:
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:
@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):
- Variable d'entorn — sobreescriu tot; útil per secrets en CI/CD
SystemConfiga la BD — configurable per admins via UI en runtime- Valor per defecte al codi — fallback si no hi ha cap dels anteriors
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):
# 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
StorageServicei 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
Projectfiltrades peruser_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→ desaOntology+Grapha BDSimulationRunner→ desa.dbOASIS aStorageService, metadades aSimulationReportAgent→ desa seccions aStorageService, metadades aReportSystemConfigoperatiu (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.devi.env.prodper 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