docs(persistencia): afegir especificació completa de la capa de persistència
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 <noreply@anthropic.com>
This commit is contained in:
parent
49f4da51b1
commit
36107c2c69
|
|
@ -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
|
||||
```
|
||||
Loading…
Reference in New Issue