Fabio Antonini Claude Sonnet 4.6 commited on
Commit
76f414d
·
1 Parent(s): a191297

feat: add FastAPI backend (Phase 1 — feature/backend)

Browse files

New backend/ package with 35 REST endpoints:
- config.py: pydantic-settings (DB, MinIO, JWT, Ollama, CORS)
- database.py: async SQLAlchemy engine + session dependency
- models/user.py: User, Organization, Role (admin/examiner/viewer)
- models/project.py: Project, Document, Analysis ORM models
- auth/password.py: bcrypt hash/verify
- auth/jwt.py: access token (30min) + refresh token (7d)
- auth/dependencies.py: get_current_user(), require_role()
- routers/auth.py: POST /auth/login|refresh|logout
- routers/users.py: CRUD /users/ + /users/me
- routers/projects.py: CRUD /projects/ + document upload/delete
- routers/analysis.py: POST /analysis/{htr,sig,ner,writer,grapho,pipeline,dating}
- routers/rag.py: streaming SSE /rag/chat + doc management
- storage/minio_client.py: async MinIO wrapper (anyio thread offload)
- main.py: FastAPI app with CORS, lifespan, health check
- Dockerfile: backend container image
- .env.example: environment variable template

Also:
- requirements-backend.txt: fastapi, uvicorn, sqlalchemy[asyncio],
asyncpg, pydantic-settings, python-jose, passlib, minio, anyio
- docker-compose.yml: added postgres, minio, backend, ollama services

All AI processing delegated to core/ — backend is a thin HTTP layer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

backend/.env.example ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copy this file to .env and fill in the values before running the backend.
2
+ # Never commit .env to git.
3
+
4
+ # JWT — generate with: openssl rand -hex 32
5
+ SECRET_KEY=change_me_in_production
6
+
7
+ # Database
8
+ DATABASE_URL=postgresql+asyncpg://grapholab:grapholab@localhost:5432/grapholab
9
+
10
+ # MinIO
11
+ MINIO_ENDPOINT=localhost:9000
12
+ MINIO_ACCESS_KEY=grapholab
13
+ MINIO_SECRET_KEY=grapholab123
14
+ MINIO_BUCKET=grapholab-docs
15
+ MINIO_SECURE=false
16
+
17
+ # Ollama
18
+ OLLAMA_HOST=http://localhost:11434
19
+ OLLAMA_MODEL=llama3.2
20
+
21
+ # Debug (set to true during development)
22
+ DEBUG=false
backend/Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # System deps
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ libgl1 libglib2.0-0 libsm6 libxext6 libxrender-dev \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ # Python deps
11
+ COPY requirements.txt requirements-backend.txt ./
12
+ RUN pip install --no-cache-dir -r requirements.txt -r requirements-backend.txt
13
+
14
+ # Application code
15
+ COPY core/ ./core/
16
+ COPY backend/ ./backend/
17
+ COPY data/ ./data/
18
+
19
+ ENV PYTHONPATH=/app
20
+ CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
backend/__init__.py ADDED
File without changes
backend/auth/__init__.py ADDED
File without changes
backend/auth/dependencies.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GraphoLab Backend — FastAPI auth dependencies.
3
+
4
+ Usage in routers:
5
+ current_user: User = Depends(get_current_user)
6
+ current_user: User = Depends(require_role(Role.admin))
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from fastapi import Depends, HTTPException, status
12
+ from fastapi.security import OAuth2PasswordBearer
13
+ from jose import JWTError
14
+ from sqlalchemy import select
15
+ from sqlalchemy.ext.asyncio import AsyncSession
16
+
17
+ from backend.auth.jwt import TokenType, decode_token
18
+ from backend.database import get_db
19
+ from backend.models.user import Role, User
20
+
21
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
22
+
23
+
24
+ async def get_current_user(
25
+ token: str = Depends(oauth2_scheme),
26
+ db: AsyncSession = Depends(get_db),
27
+ ) -> User:
28
+ credentials_exc = HTTPException(
29
+ status_code=status.HTTP_401_UNAUTHORIZED,
30
+ detail="Credenziali non valide o sessione scaduta.",
31
+ headers={"WWW-Authenticate": "Bearer"},
32
+ )
33
+ try:
34
+ payload = decode_token(token)
35
+ if payload.get("type") != TokenType.access:
36
+ raise credentials_exc
37
+ user_id = int(payload["sub"])
38
+ except (JWTError, KeyError, ValueError):
39
+ raise credentials_exc
40
+
41
+ result = await db.execute(select(User).where(User.id == user_id))
42
+ user = result.scalar_one_or_none()
43
+ if user is None or not user.is_active:
44
+ raise credentials_exc
45
+ return user
46
+
47
+
48
+ def require_role(*roles: Role):
49
+ """Dependency factory: allow only users with one of the given roles."""
50
+ async def _check(current_user: User = Depends(get_current_user)) -> User:
51
+ if current_user.role not in roles:
52
+ raise HTTPException(
53
+ status_code=status.HTTP_403_FORBIDDEN,
54
+ detail="Permessi insufficienti per questa operazione.",
55
+ )
56
+ return current_user
57
+ return _check
backend/auth/jwt.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GraphoLab Backend — JWT token creation and verification.
3
+
4
+ Access tokens: short-lived (30 min default), carry user_id + role.
5
+ Refresh tokens: long-lived (7 days), used only to issue new access tokens.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime, timedelta, timezone
11
+ from enum import Enum
12
+
13
+ from jose import JWTError, jwt
14
+
15
+ from backend.config import settings
16
+
17
+
18
+ class TokenType(str, Enum):
19
+ access = "access"
20
+ refresh = "refresh"
21
+
22
+
23
+ def _now() -> datetime:
24
+ return datetime.now(tz=timezone.utc)
25
+
26
+
27
+ def create_access_token(user_id: int, role: str) -> str:
28
+ expire = _now() + timedelta(minutes=settings.access_token_expire_minutes)
29
+ payload = {
30
+ "sub": str(user_id),
31
+ "role": role,
32
+ "type": TokenType.access,
33
+ "exp": expire,
34
+ }
35
+ return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
36
+
37
+
38
+ def create_refresh_token(user_id: int) -> str:
39
+ expire = _now() + timedelta(days=settings.refresh_token_expire_days)
40
+ payload = {
41
+ "sub": str(user_id),
42
+ "type": TokenType.refresh,
43
+ "exp": expire,
44
+ }
45
+ return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
46
+
47
+
48
+ def decode_token(token: str) -> dict:
49
+ """Decode and validate a JWT. Raises JWTError on failure."""
50
+ return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
backend/auth/password.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GraphoLab Backend — Password hashing with bcrypt.
3
+ """
4
+
5
+ from passlib.context import CryptContext
6
+
7
+ _ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
8
+
9
+
10
+ def hash_password(plain: str) -> str:
11
+ return _ctx.hash(plain)
12
+
13
+
14
+ def verify_password(plain: str, hashed: str) -> bool:
15
+ return _ctx.verify(plain, hashed)
backend/config.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GraphoLab Backend — Application Settings.
3
+
4
+ All values can be overridden via environment variables or a .env file.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from pydantic_settings import BaseSettings, SettingsConfigDict
11
+
12
+
13
+ class Settings(BaseSettings):
14
+ model_config = SettingsConfigDict(
15
+ env_file=".env",
16
+ env_file_encoding="utf-8",
17
+ case_sensitive=False,
18
+ extra="ignore",
19
+ )
20
+
21
+ # ── Application ───────────────────────────────────────────────────────────
22
+ app_name: str = "GraphoLab API"
23
+ app_version: str = "0.1.0"
24
+ debug: bool = False
25
+
26
+ # ── Security / JWT ────────────────────────────────────────────────────────
27
+ secret_key: str = "CHANGE_ME_in_production_use_openssl_rand_hex_32"
28
+ algorithm: str = "HS256"
29
+ access_token_expire_minutes: int = 30
30
+ refresh_token_expire_days: int = 7
31
+
32
+ # ── Database (PostgreSQL) ─────────────────────────────────────────────────
33
+ database_url: str = "postgresql+asyncpg://grapholab:grapholab@localhost:5432/grapholab"
34
+
35
+ # ── MinIO (S3-compatible storage) ─────────────────────────────────────────
36
+ minio_endpoint: str = "localhost:9000"
37
+ minio_access_key: str = "grapholab"
38
+ minio_secret_key: str = "grapholab123"
39
+ minio_bucket: str = "grapholab-docs"
40
+ minio_secure: bool = False
41
+
42
+ # ── AI model paths (mirrors Gradio demo defaults) ─────────────────────────
43
+ signet_weights: Path = Path("data/signet.pth")
44
+ writer_samples_dir: Path = Path("data/samples")
45
+ rag_cache_dir: Path = Path("data/rag_cache")
46
+
47
+ # ── Ollama ────────────────────────────────────────────────────────────────
48
+ ollama_host: str = "http://localhost:11434"
49
+ ollama_model: str = "llama3.2"
50
+
51
+ # ── CORS (comma-separated origins for the React frontend) ─────────────────
52
+ cors_origins: list[str] = ["http://localhost:3000", "http://localhost:5173"]
53
+
54
+
55
+ settings = Settings()
backend/database.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GraphoLab Backend — Async SQLAlchemy database setup.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
8
+ from sqlalchemy.orm import DeclarativeBase
9
+
10
+ from backend.config import settings
11
+
12
+ engine = create_async_engine(
13
+ settings.database_url,
14
+ echo=settings.debug,
15
+ pool_pre_ping=True,
16
+ )
17
+
18
+ AsyncSessionLocal = async_sessionmaker(
19
+ bind=engine,
20
+ class_=AsyncSession,
21
+ expire_on_commit=False,
22
+ )
23
+
24
+
25
+ class Base(DeclarativeBase):
26
+ """Shared declarative base for all ORM models."""
27
+ pass
28
+
29
+
30
+ async def get_db() -> AsyncSession:
31
+ """FastAPI dependency: yields an async DB session per request."""
32
+ async with AsyncSessionLocal() as session:
33
+ try:
34
+ yield session
35
+ await session.commit()
36
+ except Exception:
37
+ await session.rollback()
38
+ raise
39
+
40
+
41
+ async def init_db() -> None:
42
+ """Create all tables (called at startup if they don't exist yet)."""
43
+ async with engine.begin() as conn:
44
+ await conn.run_sync(Base.metadata.create_all)
backend/main.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GraphoLab Backend — FastAPI application entry point.
3
+
4
+ Run locally:
5
+ uvicorn backend.main:app --reload --port 8000
6
+
7
+ Interactive API docs:
8
+ http://localhost:8000/docs (Swagger UI)
9
+ http://localhost:8000/redoc (ReDoc)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from contextlib import asynccontextmanager
15
+
16
+ from fastapi import FastAPI
17
+ from fastapi.middleware.cors import CORSMiddleware
18
+
19
+ from backend.config import settings
20
+ from backend.database import init_db
21
+ from backend.routers import auth, users, projects, analysis, rag
22
+
23
+
24
+ @asynccontextmanager
25
+ async def lifespan(app: FastAPI):
26
+ """Startup / shutdown lifecycle."""
27
+ await init_db()
28
+ yield
29
+
30
+
31
+ app = FastAPI(
32
+ title=settings.app_name,
33
+ version=settings.app_version,
34
+ docs_url="/docs",
35
+ redoc_url="/redoc",
36
+ lifespan=lifespan,
37
+ )
38
+
39
+ # ── CORS (allow React dev server) ─────────────────────────────────────────────
40
+ app.add_middleware(
41
+ CORSMiddleware,
42
+ allow_origins=settings.cors_origins,
43
+ allow_credentials=True,
44
+ allow_methods=["*"],
45
+ allow_headers=["*"],
46
+ )
47
+
48
+ # ── Routers ───────────────────────────────────────────────────────────────────
49
+ app.include_router(auth.router)
50
+ app.include_router(users.router)
51
+ app.include_router(projects.router)
52
+ app.include_router(analysis.router)
53
+ app.include_router(rag.router)
54
+
55
+
56
+ # ── Health check ──────────────────────────────────────────────────────────────
57
+ @app.get("/health", tags=["system"])
58
+ async def health() -> dict:
59
+ return {"status": "ok", "version": settings.app_version}
backend/models/__init__.py ADDED
File without changes
backend/models/project.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GraphoLab Backend — Project and Analysis ORM models.
3
+
4
+ A Project (= "Perizia") groups one or more Documents and their Analyses.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import enum
10
+ from datetime import datetime
11
+
12
+ from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text, func
13
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
14
+
15
+ from backend.database import Base
16
+
17
+
18
+ class ProjectStatus(str, enum.Enum):
19
+ draft = "draft"
20
+ in_progress = "in_progress"
21
+ completed = "completed"
22
+ archived = "archived"
23
+
24
+
25
+ class AnalysisType(str, enum.Enum):
26
+ htr = "htr"
27
+ signature_detection = "signature_detection"
28
+ signature_verification = "signature_verification"
29
+ ner = "ner"
30
+ writer_identification = "writer_identification"
31
+ graphology = "graphology"
32
+ pipeline = "pipeline"
33
+ dating = "dating"
34
+ rag = "rag"
35
+
36
+
37
+ class Project(Base):
38
+ __tablename__ = "projects"
39
+
40
+ id: Mapped[int] = mapped_column(primary_key=True, index=True)
41
+ title: Mapped[str] = mapped_column(String(256), nullable=False)
42
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
43
+ status: Mapped[ProjectStatus] = mapped_column(
44
+ Enum(ProjectStatus), default=ProjectStatus.draft, nullable=False
45
+ )
46
+
47
+ owner_id: Mapped[int] = mapped_column(
48
+ ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
49
+ )
50
+ owner: Mapped["User"] = relationship("User", back_populates="projects")
51
+
52
+ created_at: Mapped[datetime] = mapped_column(
53
+ DateTime(timezone=True), server_default=func.now()
54
+ )
55
+ updated_at: Mapped[datetime] = mapped_column(
56
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
57
+ )
58
+
59
+ documents: Mapped[list[Document]] = relationship(
60
+ "Document", back_populates="project", cascade="all, delete-orphan"
61
+ )
62
+ analyses: Mapped[list[Analysis]] = relationship(
63
+ "Analysis", back_populates="project", cascade="all, delete-orphan"
64
+ )
65
+
66
+
67
+ class Document(Base):
68
+ """A file uploaded to a project (stored in MinIO)."""
69
+
70
+ __tablename__ = "documents"
71
+
72
+ id: Mapped[int] = mapped_column(primary_key=True, index=True)
73
+ filename: Mapped[str] = mapped_column(String(512), nullable=False)
74
+ content_type: Mapped[str] = mapped_column(String(128), nullable=False)
75
+ storage_key: Mapped[str] = mapped_column(String(512), nullable=False) # MinIO object key
76
+ size_bytes: Mapped[int] = mapped_column(Integer, default=0)
77
+
78
+ project_id: Mapped[int] = mapped_column(
79
+ ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
80
+ )
81
+ project: Mapped[Project] = relationship("Project", back_populates="documents")
82
+
83
+ uploaded_at: Mapped[datetime] = mapped_column(
84
+ DateTime(timezone=True), server_default=func.now()
85
+ )
86
+
87
+
88
+ class Analysis(Base):
89
+ """Result of one AI analysis step on a project document."""
90
+
91
+ __tablename__ = "analyses"
92
+
93
+ id: Mapped[int] = mapped_column(primary_key=True, index=True)
94
+ analysis_type: Mapped[AnalysisType] = mapped_column(Enum(AnalysisType), nullable=False)
95
+ result_text: Mapped[str | None] = mapped_column(Text, nullable=True)
96
+ result_storage_key: Mapped[str | None] = mapped_column(
97
+ String(512), nullable=True # MinIO key for image/PDF result
98
+ )
99
+
100
+ document_id: Mapped[int | None] = mapped_column(
101
+ ForeignKey("documents.id", ondelete="SET NULL"), nullable=True
102
+ )
103
+ project_id: Mapped[int] = mapped_column(
104
+ ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
105
+ )
106
+ project: Mapped[Project] = relationship("Project", back_populates="analyses")
107
+
108
+ created_at: Mapped[datetime] = mapped_column(
109
+ DateTime(timezone=True), server_default=func.now()
110
+ )
backend/models/user.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GraphoLab Backend — User and Organization ORM models.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import enum
8
+ from datetime import datetime
9
+
10
+ from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, func
11
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
12
+
13
+ from backend.database import Base
14
+
15
+
16
+ class Role(str, enum.Enum):
17
+ admin = "admin"
18
+ examiner = "examiner" # perito — full read/write on own projects
19
+ viewer = "viewer" # sola lettura
20
+
21
+
22
+ class Organization(Base):
23
+ __tablename__ = "organizations"
24
+
25
+ id: Mapped[int] = mapped_column(primary_key=True, index=True)
26
+ name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
27
+ created_at: Mapped[datetime] = mapped_column(
28
+ DateTime(timezone=True), server_default=func.now()
29
+ )
30
+
31
+ users: Mapped[list[User]] = relationship("User", back_populates="organization")
32
+
33
+
34
+ class User(Base):
35
+ __tablename__ = "users"
36
+
37
+ id: Mapped[int] = mapped_column(primary_key=True, index=True)
38
+ email: Mapped[str] = mapped_column(String(256), unique=True, index=True, nullable=False)
39
+ full_name: Mapped[str] = mapped_column(String(256), nullable=False)
40
+ hashed_password: Mapped[str] = mapped_column(String(256), nullable=False)
41
+ role: Mapped[Role] = mapped_column(Enum(Role), default=Role.examiner, nullable=False)
42
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
43
+
44
+ organization_id: Mapped[int | None] = mapped_column(
45
+ ForeignKey("organizations.id", ondelete="SET NULL"), nullable=True
46
+ )
47
+ organization: Mapped[Organization | None] = relationship(
48
+ "Organization", back_populates="users"
49
+ )
50
+
51
+ created_at: Mapped[datetime] = mapped_column(
52
+ DateTime(timezone=True), server_default=func.now()
53
+ )
54
+ updated_at: Mapped[datetime] = mapped_column(
55
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
56
+ )
57
+
58
+ projects: Mapped[list] = relationship("Project", back_populates="owner")
backend/routers/__init__.py ADDED
File without changes
backend/routers/analysis.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GraphoLab Backend — Analysis router.
3
+
4
+ All heavy AI work is delegated to core/. This router is a thin HTTP layer:
5
+ 1. Download the document image from MinIO
6
+ 2. Call the appropriate core/ function
7
+ 3. Persist the result text in the DB
8
+ 4. Return the result to the client
9
+
10
+ Endpoints:
11
+ POST /analysis/htr → HTR transcription
12
+ POST /analysis/signature-detection → YOLO signature detection
13
+ POST /analysis/signature-verification → SigNet verification
14
+ POST /analysis/ner → Named Entity Recognition
15
+ POST /analysis/writer → Writer identification
16
+ POST /analysis/graphology → Graphological analysis
17
+ POST /analysis/pipeline → Full 7-step forensic pipeline
18
+ POST /analysis/dating → Document dating
19
+ GET /analysis/project/{project_id} → List analyses for a project
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import io
25
+ from pathlib import Path
26
+
27
+ import numpy as np
28
+ from fastapi import APIRouter, Depends, HTTPException, status
29
+ from fastapi.responses import StreamingResponse
30
+ from PIL import Image
31
+ from pydantic import BaseModel
32
+ from sqlalchemy import select
33
+ from sqlalchemy.ext.asyncio import AsyncSession
34
+
35
+ from backend.auth.dependencies import get_current_user
36
+ from backend.config import settings
37
+ from backend.database import get_db
38
+ from backend.models.project import Analysis, AnalysisType, Document, Project
39
+ from backend.models.user import Role, User
40
+ from backend.storage.minio_client import download_object
41
+
42
+ router = APIRouter(prefix="/analysis", tags=["analysis"])
43
+
44
+
45
+ # ── Schemas ───────────────────────────────────────────────────────────────────
46
+
47
+ class AnalysisRequest(BaseModel):
48
+ project_id: int
49
+ document_id: int
50
+
51
+
52
+ class SignatureVerifyRequest(BaseModel):
53
+ project_id: int
54
+ document_id: int # document under examination
55
+ reference_document_id: int # known genuine signature document
56
+
57
+
58
+ class AnalysisOut(BaseModel):
59
+ id: int
60
+ analysis_type: AnalysisType
61
+ result_text: str | None
62
+ project_id: int
63
+ document_id: int | None
64
+
65
+ model_config = {"from_attributes": True}
66
+
67
+
68
+ # ── Helpers ───────────────────────────────────────────────────────────────────
69
+
70
+ async def _load_image(doc: Document) -> np.ndarray:
71
+ """Download a document from MinIO and return it as an RGB numpy array."""
72
+ data = await download_object(doc.storage_key)
73
+ img = Image.open(io.BytesIO(data)).convert("RGB")
74
+ return np.array(img)
75
+
76
+
77
+ async def _get_doc(doc_id: int, project_id: int, db: AsyncSession) -> Document:
78
+ result = await db.execute(
79
+ select(Document).where(Document.id == doc_id, Document.project_id == project_id)
80
+ )
81
+ doc = result.scalar_one_or_none()
82
+ if doc is None:
83
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Documento non trovato.")
84
+ return doc
85
+
86
+
87
+ async def _check_project_access(project_id: int, db: AsyncSession, user: User) -> Project:
88
+ result = await db.execute(select(Project).where(Project.id == project_id))
89
+ project = result.scalar_one_or_none()
90
+ if project is None:
91
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Perizia non trovata.")
92
+ if user.role != Role.admin and project.owner_id != user.id:
93
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Accesso negato.")
94
+ return project
95
+
96
+
97
+ async def _save_analysis(
98
+ db: AsyncSession,
99
+ project_id: int,
100
+ doc_id: int | None,
101
+ analysis_type: AnalysisType,
102
+ result_text: str,
103
+ ) -> Analysis:
104
+ analysis = Analysis(
105
+ analysis_type=analysis_type,
106
+ result_text=result_text,
107
+ project_id=project_id,
108
+ document_id=doc_id,
109
+ )
110
+ db.add(analysis)
111
+ await db.flush()
112
+ return analysis
113
+
114
+
115
+ # ── Endpoints ─────────────────────────────────────────────────────────────────
116
+
117
+ @router.post("/htr", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
118
+ async def run_htr(
119
+ body: AnalysisRequest,
120
+ db: AsyncSession = Depends(get_db),
121
+ current_user: User = Depends(get_current_user),
122
+ ) -> Analysis:
123
+ await _check_project_access(body.project_id, db, current_user)
124
+ doc = await _get_doc(body.document_id, body.project_id, db)
125
+ image = await _load_image(doc)
126
+
127
+ from core.ocr import htr_transcribe
128
+ text = htr_transcribe(image)
129
+
130
+ return await _save_analysis(db, body.project_id, doc.id, AnalysisType.htr, text)
131
+
132
+
133
+ @router.post("/signature-detection", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
134
+ async def run_signature_detection(
135
+ body: AnalysisRequest,
136
+ db: AsyncSession = Depends(get_db),
137
+ current_user: User = Depends(get_current_user),
138
+ ) -> Analysis:
139
+ await _check_project_access(body.project_id, db, current_user)
140
+ doc = await _get_doc(body.document_id, body.project_id, db)
141
+ image = await _load_image(doc)
142
+
143
+ from core.signature import detect_and_crop
144
+ _, _, summary = detect_and_crop(image)
145
+
146
+ return await _save_analysis(
147
+ db, body.project_id, doc.id, AnalysisType.signature_detection, summary
148
+ )
149
+
150
+
151
+ @router.post("/signature-verification", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
152
+ async def run_signature_verification(
153
+ body: SignatureVerifyRequest,
154
+ db: AsyncSession = Depends(get_db),
155
+ current_user: User = Depends(get_current_user),
156
+ ) -> Analysis:
157
+ await _check_project_access(body.project_id, db, current_user)
158
+ doc = await _get_doc(body.document_id, body.project_id, db)
159
+ ref_doc = await _get_doc(body.reference_document_id, body.project_id, db)
160
+
161
+ image = await _load_image(doc)
162
+ ref_image = await _load_image(ref_doc)
163
+
164
+ from core.signature import detect_and_crop, sig_verify
165
+ _, query_crop, _ = detect_and_crop(image)
166
+ query = query_crop if query_crop is not None else image
167
+
168
+ report, _ = sig_verify(ref_image, None, query, settings.signet_weights)
169
+
170
+ return await _save_analysis(
171
+ db, body.project_id, doc.id, AnalysisType.signature_verification, report
172
+ )
173
+
174
+
175
+ @router.post("/ner", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
176
+ async def run_ner(
177
+ body: AnalysisRequest,
178
+ db: AsyncSession = Depends(get_db),
179
+ current_user: User = Depends(get_current_user),
180
+ ) -> Analysis:
181
+ await _check_project_access(body.project_id, db, current_user)
182
+ doc = await _get_doc(body.document_id, body.project_id, db)
183
+ image = await _load_image(doc)
184
+
185
+ from core.ocr import htr_transcribe
186
+ from core.ner import ner_extract
187
+ text = htr_transcribe(image)
188
+ _, ner_summary = ner_extract(text)
189
+
190
+ return await _save_analysis(db, body.project_id, doc.id, AnalysisType.ner, ner_summary)
191
+
192
+
193
+ @router.post("/writer", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
194
+ async def run_writer_identification(
195
+ body: AnalysisRequest,
196
+ db: AsyncSession = Depends(get_db),
197
+ current_user: User = Depends(get_current_user),
198
+ ) -> Analysis:
199
+ await _check_project_access(body.project_id, db, current_user)
200
+ doc = await _get_doc(body.document_id, body.project_id, db)
201
+ image = await _load_image(doc)
202
+
203
+ from core.writer import writer_identify
204
+ report, _ = writer_identify(image, settings.writer_samples_dir)
205
+
206
+ return await _save_analysis(
207
+ db, body.project_id, doc.id, AnalysisType.writer_identification, report
208
+ )
209
+
210
+
211
+ @router.post("/graphology", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
212
+ async def run_graphology(
213
+ body: AnalysisRequest,
214
+ db: AsyncSession = Depends(get_db),
215
+ current_user: User = Depends(get_current_user),
216
+ ) -> Analysis:
217
+ await _check_project_access(body.project_id, db, current_user)
218
+ doc = await _get_doc(body.document_id, body.project_id, db)
219
+ image = await _load_image(doc)
220
+
221
+ from core.graphology import grapho_analyse
222
+ report, _ = grapho_analyse(image)
223
+
224
+ return await _save_analysis(
225
+ db, body.project_id, doc.id, AnalysisType.graphology, report
226
+ )
227
+
228
+
229
+ @router.post("/dating", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
230
+ async def run_dating(
231
+ body: AnalysisRequest,
232
+ db: AsyncSession = Depends(get_db),
233
+ current_user: User = Depends(get_current_user),
234
+ ) -> Analysis:
235
+ await _check_project_access(body.project_id, db, current_user)
236
+ doc = await _get_doc(body.document_id, body.project_id, db)
237
+ image = await _load_image(doc)
238
+
239
+ from core.ocr import htr_transcribe
240
+ from core.dating import extract_dates
241
+ text = htr_transcribe(image)
242
+ dates = extract_dates(text)
243
+ if dates:
244
+ result = "\n".join(f"- {raw} → {dt.strftime('%Y-%m-%d')}" for raw, dt in dates)
245
+ else:
246
+ result = "Nessuna data rilevata nel documento."
247
+
248
+ return await _save_analysis(db, body.project_id, doc.id, AnalysisType.dating, result)
249
+
250
+
251
+ @router.post("/pipeline", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
252
+ async def run_pipeline(
253
+ body: AnalysisRequest,
254
+ db: AsyncSession = Depends(get_db),
255
+ current_user: User = Depends(get_current_user),
256
+ ) -> Analysis:
257
+ """Run the full 7-step forensic pipeline (blocking — may take 30–120 s)."""
258
+ await _check_project_access(body.project_id, db, current_user)
259
+ doc = await _get_doc(body.document_id, body.project_id, db)
260
+ image = await _load_image(doc)
261
+
262
+ from core.pipeline import run_pipeline_steps
263
+ results = None
264
+ for results in run_pipeline_steps(
265
+ image, None, settings.signet_weights, settings.writer_samples_dir
266
+ ):
267
+ pass # consume generator to completion
268
+
269
+ report = results.final_report if results else "Pipeline non completata."
270
+ return await _save_analysis(db, body.project_id, doc.id, AnalysisType.pipeline, report)
271
+
272
+
273
+ @router.get("/project/{project_id}", response_model=list[AnalysisOut])
274
+ async def list_analyses(
275
+ project_id: int,
276
+ db: AsyncSession = Depends(get_db),
277
+ current_user: User = Depends(get_current_user),
278
+ ) -> list[Analysis]:
279
+ await _check_project_access(project_id, db, current_user)
280
+ result = await db.execute(
281
+ select(Analysis)
282
+ .where(Analysis.project_id == project_id)
283
+ .order_by(Analysis.created_at.desc())
284
+ )
285
+ return result.scalars().all()
backend/routers/auth.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GraphoLab Backend — Authentication router.
3
+
4
+ Endpoints:
5
+ POST /auth/login → access + refresh tokens (OAuth2 password flow)
6
+ POST /auth/refresh → new access token from refresh token
7
+ POST /auth/logout → client-side only (stateless JWT)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from fastapi import APIRouter, Depends, HTTPException, status
13
+ from fastapi.security import OAuth2PasswordRequestForm
14
+ from jose import JWTError
15
+ from pydantic import BaseModel
16
+ from sqlalchemy import select
17
+ from sqlalchemy.ext.asyncio import AsyncSession
18
+
19
+ from backend.auth.jwt import TokenType, create_access_token, create_refresh_token, decode_token
20
+ from backend.auth.password import verify_password
21
+ from backend.database import get_db
22
+ from backend.models.user import User
23
+
24
+ router = APIRouter(prefix="/auth", tags=["auth"])
25
+
26
+
27
+ class TokenResponse(BaseModel):
28
+ access_token: str
29
+ refresh_token: str
30
+ token_type: str = "bearer"
31
+
32
+
33
+ class RefreshRequest(BaseModel):
34
+ refresh_token: str
35
+
36
+
37
+ @router.post("/login", response_model=TokenResponse)
38
+ async def login(
39
+ form: OAuth2PasswordRequestForm = Depends(),
40
+ db: AsyncSession = Depends(get_db),
41
+ ) -> TokenResponse:
42
+ result = await db.execute(select(User).where(User.email == form.username))
43
+ user = result.scalar_one_or_none()
44
+
45
+ if user is None or not verify_password(form.password, user.hashed_password):
46
+ raise HTTPException(
47
+ status_code=status.HTTP_401_UNAUTHORIZED,
48
+ detail="Email o password non corretti.",
49
+ headers={"WWW-Authenticate": "Bearer"},
50
+ )
51
+ if not user.is_active:
52
+ raise HTTPException(
53
+ status_code=status.HTTP_403_FORBIDDEN,
54
+ detail="Account disabilitato. Contatta l'amministratore.",
55
+ )
56
+
57
+ return TokenResponse(
58
+ access_token=create_access_token(user.id, user.role),
59
+ refresh_token=create_refresh_token(user.id),
60
+ )
61
+
62
+
63
+ @router.post("/refresh", response_model=TokenResponse)
64
+ async def refresh(
65
+ body: RefreshRequest,
66
+ db: AsyncSession = Depends(get_db),
67
+ ) -> TokenResponse:
68
+ credentials_exc = HTTPException(
69
+ status_code=status.HTTP_401_UNAUTHORIZED,
70
+ detail="Refresh token non valido o scaduto.",
71
+ )
72
+ try:
73
+ payload = decode_token(body.refresh_token)
74
+ if payload.get("type") != TokenType.refresh:
75
+ raise credentials_exc
76
+ user_id = int(payload["sub"])
77
+ except (JWTError, KeyError, ValueError):
78
+ raise credentials_exc
79
+
80
+ result = await db.execute(select(User).where(User.id == user_id))
81
+ user = result.scalar_one_or_none()
82
+ if user is None or not user.is_active:
83
+ raise credentials_exc
84
+
85
+ return TokenResponse(
86
+ access_token=create_access_token(user.id, user.role),
87
+ refresh_token=create_refresh_token(user.id),
88
+ )
89
+
90
+
91
+ @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
92
+ async def logout() -> None:
93
+ """JWT is stateless — logout is handled client-side by discarding the token."""
94
+ pass
backend/routers/projects.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GraphoLab Backend — Projects router.
3
+
4
+ A project (= "Perizia") groups documents and analyses for one case.
5
+
6
+ Endpoints:
7
+ GET /projects/ → list own projects
8
+ POST /projects/ → create project
9
+ GET /projects/{id} → get project detail
10
+ PUT /projects/{id} → update project
11
+ DELETE /projects/{id} → delete project
12
+ POST /projects/{id}/documents → upload document to project
13
+ GET /projects/{id}/documents → list project documents
14
+ DELETE /projects/{id}/documents/{doc_id} → remove document
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
20
+ from pydantic import BaseModel
21
+ from sqlalchemy import select
22
+ from sqlalchemy.ext.asyncio import AsyncSession
23
+ from sqlalchemy.orm import selectinload
24
+
25
+ from backend.auth.dependencies import get_current_user
26
+ from backend.database import get_db
27
+ from backend.models.project import Document, Project, ProjectStatus
28
+ from backend.models.user import Role, User
29
+ from backend.storage.minio_client import delete_object, upload_fileobj
30
+
31
+ router = APIRouter(prefix="/projects", tags=["projects"])
32
+
33
+
34
+ # ── Schemas ───────────────────────────────────────────────────────────────────
35
+
36
+ class ProjectOut(BaseModel):
37
+ id: int
38
+ title: str
39
+ description: str | None
40
+ status: ProjectStatus
41
+ owner_id: int
42
+ document_count: int = 0
43
+
44
+ model_config = {"from_attributes": True}
45
+
46
+
47
+ class ProjectCreate(BaseModel):
48
+ title: str
49
+ description: str | None = None
50
+
51
+
52
+ class ProjectUpdate(BaseModel):
53
+ title: str | None = None
54
+ description: str | None = None
55
+ status: ProjectStatus | None = None
56
+
57
+
58
+ class DocumentOut(BaseModel):
59
+ id: int
60
+ filename: str
61
+ content_type: str
62
+ size_bytes: int
63
+ storage_key: str
64
+
65
+ model_config = {"from_attributes": True}
66
+
67
+
68
+ # ── Helpers ───────────────────────────────────────────────────────────────────
69
+
70
+ async def _get_project_or_404(
71
+ project_id: int,
72
+ db: AsyncSession,
73
+ current_user: User,
74
+ ) -> Project:
75
+ result = await db.execute(
76
+ select(Project)
77
+ .options(selectinload(Project.documents))
78
+ .where(Project.id == project_id)
79
+ )
80
+ project = result.scalar_one_or_none()
81
+ if project is None:
82
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Perizia non trovata.")
83
+ # Admins can access all projects; examiners/viewers only their own
84
+ if current_user.role != Role.admin and project.owner_id != current_user.id:
85
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Accesso negato.")
86
+ return project
87
+
88
+
89
+ # ── Project CRUD ──────────────────────────────────────────────────────────────
90
+
91
+ @router.get("/", response_model=list[ProjectOut])
92
+ async def list_projects(
93
+ db: AsyncSession = Depends(get_db),
94
+ current_user: User = Depends(get_current_user),
95
+ ) -> list[Project]:
96
+ if current_user.role == Role.admin:
97
+ q = select(Project).options(selectinload(Project.documents)).order_by(Project.id.desc())
98
+ else:
99
+ q = (
100
+ select(Project)
101
+ .options(selectinload(Project.documents))
102
+ .where(Project.owner_id == current_user.id)
103
+ .order_by(Project.id.desc())
104
+ )
105
+ result = await db.execute(q)
106
+ projects = result.scalars().all()
107
+ for p in projects:
108
+ p.document_count = len(p.documents)
109
+ return projects
110
+
111
+
112
+ @router.post("/", response_model=ProjectOut, status_code=status.HTTP_201_CREATED)
113
+ async def create_project(
114
+ body: ProjectCreate,
115
+ db: AsyncSession = Depends(get_db),
116
+ current_user: User = Depends(get_current_user),
117
+ ) -> Project:
118
+ project = Project(
119
+ title=body.title,
120
+ description=body.description,
121
+ owner_id=current_user.id,
122
+ )
123
+ db.add(project)
124
+ await db.flush()
125
+ project.document_count = 0
126
+ return project
127
+
128
+
129
+ @router.get("/{project_id}", response_model=ProjectOut)
130
+ async def get_project(
131
+ project_id: int,
132
+ db: AsyncSession = Depends(get_db),
133
+ current_user: User = Depends(get_current_user),
134
+ ) -> Project:
135
+ project = await _get_project_or_404(project_id, db, current_user)
136
+ project.document_count = len(project.documents)
137
+ return project
138
+
139
+
140
+ @router.put("/{project_id}", response_model=ProjectOut)
141
+ async def update_project(
142
+ project_id: int,
143
+ body: ProjectUpdate,
144
+ db: AsyncSession = Depends(get_db),
145
+ current_user: User = Depends(get_current_user),
146
+ ) -> Project:
147
+ project = await _get_project_or_404(project_id, db, current_user)
148
+ if body.title is not None:
149
+ project.title = body.title
150
+ if body.description is not None:
151
+ project.description = body.description
152
+ if body.status is not None:
153
+ project.status = body.status
154
+ db.add(project)
155
+ project.document_count = len(project.documents)
156
+ return project
157
+
158
+
159
+ @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
160
+ async def delete_project(
161
+ project_id: int,
162
+ db: AsyncSession = Depends(get_db),
163
+ current_user: User = Depends(get_current_user),
164
+ ) -> None:
165
+ project = await _get_project_or_404(project_id, db, current_user)
166
+ # Remove files from MinIO
167
+ for doc in project.documents:
168
+ await delete_object(doc.storage_key)
169
+ await db.delete(project)
170
+
171
+
172
+ # ── Document upload / list / delete ───────────────────────────────────────────
173
+
174
+ @router.post("/{project_id}/documents", response_model=DocumentOut,
175
+ status_code=status.HTTP_201_CREATED)
176
+ async def upload_document(
177
+ project_id: int,
178
+ file: UploadFile = File(...),
179
+ db: AsyncSession = Depends(get_db),
180
+ current_user: User = Depends(get_current_user),
181
+ ) -> Document:
182
+ project = await _get_project_or_404(project_id, db, current_user)
183
+
184
+ storage_key = f"projects/{project.id}/{file.filename}"
185
+ content = await file.read()
186
+ size = len(content)
187
+ await upload_fileobj(storage_key, content, file.content_type or "application/octet-stream")
188
+
189
+ doc = Document(
190
+ filename=file.filename,
191
+ content_type=file.content_type or "application/octet-stream",
192
+ storage_key=storage_key,
193
+ size_bytes=size,
194
+ project_id=project.id,
195
+ )
196
+ db.add(doc)
197
+ await db.flush()
198
+ return doc
199
+
200
+
201
+ @router.get("/{project_id}/documents", response_model=list[DocumentOut])
202
+ async def list_documents(
203
+ project_id: int,
204
+ db: AsyncSession = Depends(get_db),
205
+ current_user: User = Depends(get_current_user),
206
+ ) -> list[Document]:
207
+ project = await _get_project_or_404(project_id, db, current_user)
208
+ return project.documents
209
+
210
+
211
+ @router.delete("/{project_id}/documents/{doc_id}", status_code=status.HTTP_204_NO_CONTENT)
212
+ async def delete_document(
213
+ project_id: int,
214
+ doc_id: int,
215
+ db: AsyncSession = Depends(get_db),
216
+ current_user: User = Depends(get_current_user),
217
+ ) -> None:
218
+ await _get_project_or_404(project_id, db, current_user)
219
+ result = await db.execute(
220
+ select(Document).where(Document.id == doc_id, Document.project_id == project_id)
221
+ )
222
+ doc = result.scalar_one_or_none()
223
+ if doc is None:
224
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Documento non trovato.")
225
+ await delete_object(doc.storage_key)
226
+ await db.delete(doc)
backend/routers/rag.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GraphoLab Backend — RAG / Consulente Forense IA router.
3
+
4
+ Endpoints:
5
+ POST /rag/chat → streaming chat (SSE)
6
+ GET /rag/docs → list loaded documents
7
+ POST /rag/docs → upload and index a new document
8
+ DELETE /rag/docs/{name} → remove a document from the index
9
+ GET /rag/status → Ollama reachability check
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
15
+ from fastapi.responses import StreamingResponse
16
+ from pydantic import BaseModel
17
+
18
+ from backend.auth.dependencies import get_current_user
19
+ from backend.config import settings
20
+ from backend.models.user import User
21
+
22
+ router = APIRouter(prefix="/rag", tags=["rag"])
23
+
24
+
25
+ # ── Schemas ───────────────────────────────────────────────────────────────────
26
+
27
+ class ChatRequest(BaseModel):
28
+ message: str
29
+ history: list[list[str]] = []
30
+
31
+
32
+ class DocInfo(BaseModel):
33
+ filename: str
34
+ chunks: int
35
+
36
+
37
+ # ── Endpoints ─────────────────────────────────────────────────────────────────
38
+
39
+ @router.get("/status")
40
+ async def rag_status(_: User = Depends(get_current_user)) -> dict:
41
+ from core.rag import check_ollama, ollama_list_models
42
+ reachable = check_ollama()
43
+ models = ollama_list_models() if reachable else []
44
+ return {"ollama_reachable": reachable, "models": models}
45
+
46
+
47
+ @router.get("/docs", response_model=list[DocInfo])
48
+ async def list_docs(_: User = Depends(get_current_user)) -> list[DocInfo]:
49
+ from core.rag import rag_doc_list
50
+ docs = rag_doc_list(settings.rag_cache_dir)
51
+ return [DocInfo(filename=d["filename"], chunks=d["chunks"]) for d in docs]
52
+
53
+
54
+ @router.post("/docs", status_code=status.HTTP_201_CREATED)
55
+ async def add_doc(
56
+ file: UploadFile = File(...),
57
+ _: User = Depends(get_current_user),
58
+ ) -> dict:
59
+ import tempfile
60
+ from pathlib import Path
61
+ from core.rag import rag_add_docs
62
+
63
+ suffix = Path(file.filename).suffix
64
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
65
+ tmp.write(await file.read())
66
+ tmp_path = tmp.name
67
+
68
+ # rag_add_docs expects a list of file-like objects with a .name attribute
69
+ class _FileLike:
70
+ name = tmp_path
71
+
72
+ result = rag_add_docs([_FileLike()], settings.rag_cache_dir)
73
+ return {"detail": result}
74
+
75
+
76
+ @router.delete("/docs/{filename}", status_code=status.HTTP_204_NO_CONTENT)
77
+ async def remove_doc(
78
+ filename: str,
79
+ _: User = Depends(get_current_user),
80
+ ) -> None:
81
+ from core.rag import rag_remove_doc
82
+ rag_remove_doc(filename, settings.rag_cache_dir)
83
+
84
+
85
+ @router.post("/chat")
86
+ async def chat(
87
+ body: ChatRequest,
88
+ _: User = Depends(get_current_user),
89
+ ) -> StreamingResponse:
90
+ """Server-Sent Events stream of the RAG response."""
91
+ from core.rag import rag_chat_stream
92
+
93
+ async def _generate():
94
+ for partial_text, sources in rag_chat_stream(body.message, body.history):
95
+ # SSE format: "data: <payload>\n\n"
96
+ yield f"data: {partial_text}\n\n"
97
+ if sources:
98
+ yield f"data: {sources}\n\n"
99
+
100
+ return StreamingResponse(_generate(), media_type="text/event-stream")
backend/routers/users.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GraphoLab Backend — Users router.
3
+
4
+ Endpoints:
5
+ GET /users/me → current user profile
6
+ PUT /users/me → update own profile / password
7
+ GET /users/ → list all users (admin only)
8
+ POST /users/ → create a new user (admin only)
9
+ DELETE /users/{id} → deactivate a user (admin only)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from fastapi import APIRouter, Depends, HTTPException, status
15
+ from pydantic import BaseModel, EmailStr
16
+ from sqlalchemy import select
17
+ from sqlalchemy.ext.asyncio import AsyncSession
18
+
19
+ from backend.auth.dependencies import get_current_user, require_role
20
+ from backend.auth.password import hash_password, verify_password
21
+ from backend.database import get_db
22
+ from backend.models.user import Role, User
23
+
24
+ router = APIRouter(prefix="/users", tags=["users"])
25
+
26
+
27
+ # ── Schemas ───────────────────────────────────────────────────────────────────
28
+
29
+ class UserOut(BaseModel):
30
+ id: int
31
+ email: str
32
+ full_name: str
33
+ role: Role
34
+ is_active: bool
35
+ organization_id: int | None
36
+
37
+ model_config = {"from_attributes": True}
38
+
39
+
40
+ class UserCreate(BaseModel):
41
+ email: EmailStr
42
+ full_name: str
43
+ password: str
44
+ role: Role = Role.examiner
45
+ organization_id: int | None = None
46
+
47
+
48
+ class UserUpdate(BaseModel):
49
+ full_name: str | None = None
50
+ current_password: str | None = None
51
+ new_password: str | None = None
52
+
53
+
54
+ # ── Endpoints ─────────────────────────────────────────────────────────────────
55
+
56
+ @router.get("/me", response_model=UserOut)
57
+ async def get_me(current_user: User = Depends(get_current_user)) -> User:
58
+ return current_user
59
+
60
+
61
+ @router.put("/me", response_model=UserOut)
62
+ async def update_me(
63
+ body: UserUpdate,
64
+ db: AsyncSession = Depends(get_db),
65
+ current_user: User = Depends(get_current_user),
66
+ ) -> User:
67
+ if body.full_name:
68
+ current_user.full_name = body.full_name
69
+
70
+ if body.new_password:
71
+ if not body.current_password:
72
+ raise HTTPException(
73
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
74
+ detail="Fornisci la password attuale per cambiarla.",
75
+ )
76
+ if not verify_password(body.current_password, current_user.hashed_password):
77
+ raise HTTPException(
78
+ status_code=status.HTTP_401_UNAUTHORIZED,
79
+ detail="Password attuale non corretta.",
80
+ )
81
+ current_user.hashed_password = hash_password(body.new_password)
82
+
83
+ db.add(current_user)
84
+ return current_user
85
+
86
+
87
+ @router.get("/", response_model=list[UserOut], dependencies=[Depends(require_role(Role.admin))])
88
+ async def list_users(db: AsyncSession = Depends(get_db)) -> list[User]:
89
+ result = await db.execute(select(User).order_by(User.id))
90
+ return result.scalars().all()
91
+
92
+
93
+ @router.post("/", response_model=UserOut, status_code=status.HTTP_201_CREATED,
94
+ dependencies=[Depends(require_role(Role.admin))])
95
+ async def create_user(
96
+ body: UserCreate,
97
+ db: AsyncSession = Depends(get_db),
98
+ ) -> User:
99
+ existing = await db.execute(select(User).where(User.email == body.email))
100
+ if existing.scalar_one_or_none():
101
+ raise HTTPException(
102
+ status_code=status.HTTP_409_CONFLICT,
103
+ detail=f"Email '{body.email}' già registrata.",
104
+ )
105
+ user = User(
106
+ email=body.email,
107
+ full_name=body.full_name,
108
+ hashed_password=hash_password(body.password),
109
+ role=body.role,
110
+ organization_id=body.organization_id,
111
+ )
112
+ db.add(user)
113
+ await db.flush()
114
+ return user
115
+
116
+
117
+ @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT,
118
+ dependencies=[Depends(require_role(Role.admin))])
119
+ async def deactivate_user(
120
+ user_id: int,
121
+ db: AsyncSession = Depends(get_db),
122
+ current_user: User = Depends(get_current_user),
123
+ ) -> None:
124
+ if user_id == current_user.id:
125
+ raise HTTPException(
126
+ status_code=status.HTTP_400_BAD_REQUEST,
127
+ detail="Non puoi disattivare il tuo stesso account.",
128
+ )
129
+ result = await db.execute(select(User).where(User.id == user_id))
130
+ user = result.scalar_one_or_none()
131
+ if user is None:
132
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Utente non trovato.")
133
+ user.is_active = False
134
+ db.add(user)
backend/storage/__init__.py ADDED
File without changes
backend/storage/minio_client.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GraphoLab Backend — MinIO async storage client.
3
+
4
+ Wraps the synchronous `minio` SDK in asyncio-friendly helpers using
5
+ `anyio.to_thread.run_sync` to avoid blocking the event loop.
6
+
7
+ Public API:
8
+ upload_fileobj(key, data, content_type) → None
9
+ download_object(key) → bytes
10
+ delete_object(key) → None
11
+ get_presigned_url(key, expires_seconds) → str
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import io
17
+ from datetime import timedelta
18
+
19
+ import anyio
20
+ from minio import Minio
21
+ from minio.error import S3Error
22
+
23
+ from backend.config import settings
24
+
25
+ # ── Singleton client ──────────────────────────────────────────────────────────
26
+
27
+ _client: Minio | None = None
28
+
29
+
30
+ def _get_client() -> Minio:
31
+ global _client
32
+ if _client is None:
33
+ _client = Minio(
34
+ settings.minio_endpoint,
35
+ access_key=settings.minio_access_key,
36
+ secret_key=settings.minio_secret_key,
37
+ secure=settings.minio_secure,
38
+ )
39
+ # Ensure the bucket exists
40
+ if not _client.bucket_exists(settings.minio_bucket):
41
+ _client.make_bucket(settings.minio_bucket)
42
+ return _client
43
+
44
+
45
+ # ── Sync helpers (run in thread pool) ────────────────────────────────────────
46
+
47
+ def _upload_sync(key: str, data: bytes, content_type: str) -> None:
48
+ client = _get_client()
49
+ client.put_object(
50
+ settings.minio_bucket,
51
+ key,
52
+ io.BytesIO(data),
53
+ length=len(data),
54
+ content_type=content_type,
55
+ )
56
+
57
+
58
+ def _download_sync(key: str) -> bytes:
59
+ client = _get_client()
60
+ response = client.get_object(settings.minio_bucket, key)
61
+ try:
62
+ return response.read()
63
+ finally:
64
+ response.close()
65
+ response.release_conn()
66
+
67
+
68
+ def _delete_sync(key: str) -> None:
69
+ client = _get_client()
70
+ try:
71
+ client.remove_object(settings.minio_bucket, key)
72
+ except S3Error:
73
+ pass # already deleted or never existed — no-op
74
+
75
+
76
+ def _presigned_sync(key: str, expires_seconds: int) -> str:
77
+ client = _get_client()
78
+ return client.presigned_get_object(
79
+ settings.minio_bucket,
80
+ key,
81
+ expires=timedelta(seconds=expires_seconds),
82
+ )
83
+
84
+
85
+ # ── Async public API ──────────────────────────────────────────────────────────
86
+
87
+ async def upload_fileobj(key: str, data: bytes, content_type: str) -> None:
88
+ await anyio.to_thread.run_sync(lambda: _upload_sync(key, data, content_type))
89
+
90
+
91
+ async def download_object(key: str) -> bytes:
92
+ return await anyio.to_thread.run_sync(lambda: _download_sync(key))
93
+
94
+
95
+ async def delete_object(key: str) -> None:
96
+ await anyio.to_thread.run_sync(lambda: _delete_sync(key))
97
+
98
+
99
+ async def get_presigned_url(key: str, expires_seconds: int = 3600) -> str:
100
+ return await anyio.to_thread.run_sync(lambda: _presigned_sync(key, expires_seconds))
docker-compose.yml CHANGED
@@ -1,4 +1,7 @@
1
  services:
 
 
 
2
  jupyter:
3
  build: .
4
  container_name: grapholab-jupyter
@@ -41,6 +44,93 @@ services:
41
  capabilities: [gpu]
42
  restart: unless-stopped
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  volumes:
45
  hf_cache:
46
  name: grapholab-hf-cache
 
 
 
 
 
 
 
1
  services:
2
+
3
+ # ── Existing services ────────────────────────────────────────────────────────
4
+
5
  jupyter:
6
  build: .
7
  container_name: grapholab-jupyter
 
44
  capabilities: [gpu]
45
  restart: unless-stopped
46
 
47
+ # ── New services (Phase 1 — professional backend) ────────────────────────────
48
+
49
+ postgres:
50
+ image: postgres:16-alpine
51
+ container_name: grapholab-postgres
52
+ ports:
53
+ - "5432:5432"
54
+ environment:
55
+ POSTGRES_USER: grapholab
56
+ POSTGRES_PASSWORD: grapholab
57
+ POSTGRES_DB: grapholab
58
+ volumes:
59
+ - postgres_data:/var/lib/postgresql/data
60
+ healthcheck:
61
+ test: ["CMD-SHELL", "pg_isready -U grapholab"]
62
+ interval: 10s
63
+ timeout: 5s
64
+ retries: 5
65
+ restart: unless-stopped
66
+
67
+ minio:
68
+ image: minio/minio:latest
69
+ container_name: grapholab-minio
70
+ command: server /data --console-address ":9001"
71
+ ports:
72
+ - "9000:9000" # S3 API
73
+ - "9001:9001" # Web console → http://localhost:9001
74
+ environment:
75
+ MINIO_ROOT_USER: grapholab
76
+ MINIO_ROOT_PASSWORD: grapholab123
77
+ volumes:
78
+ - minio_data:/data
79
+ healthcheck:
80
+ test: ["CMD", "mc", "ready", "local"]
81
+ interval: 10s
82
+ timeout: 5s
83
+ retries: 5
84
+ restart: unless-stopped
85
+
86
+ backend:
87
+ build:
88
+ context: .
89
+ dockerfile: backend/Dockerfile
90
+ container_name: grapholab-backend
91
+ ports:
92
+ - "8000:8000" # FastAPI → http://localhost:8000/docs
93
+ environment:
94
+ - DATABASE_URL=postgresql+asyncpg://grapholab:grapholab@postgres:5432/grapholab
95
+ - MINIO_ENDPOINT=minio:9000
96
+ - MINIO_ACCESS_KEY=grapholab
97
+ - MINIO_SECRET_KEY=grapholab123
98
+ - MINIO_SECURE=false
99
+ - OLLAMA_HOST=http://ollama:11434
100
+ - SECRET_KEY=${SECRET_KEY:-change_me_in_production}
101
+ - HF_HOME=/app/cache
102
+ volumes:
103
+ - ./data:/app/data
104
+ - hf_cache:/app/cache
105
+ depends_on:
106
+ postgres:
107
+ condition: service_healthy
108
+ minio:
109
+ condition: service_healthy
110
+ deploy:
111
+ resources:
112
+ reservations:
113
+ devices:
114
+ - driver: nvidia
115
+ count: all
116
+ capabilities: [gpu]
117
+ restart: unless-stopped
118
+
119
+ ollama:
120
+ image: ollama/ollama:latest
121
+ container_name: grapholab-ollama
122
+ ports:
123
+ - "11434:11434"
124
+ volumes:
125
+ - ollama_data:/root/.ollama
126
+ restart: unless-stopped
127
+
128
  volumes:
129
  hf_cache:
130
  name: grapholab-hf-cache
131
+ postgres_data:
132
+ name: grapholab-postgres-data
133
+ minio_data:
134
+ name: grapholab-minio-data
135
+ ollama_data:
136
+ name: grapholab-ollama-data
requirements-backend.txt ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GraphoLab Backend — additional dependencies (on top of requirements.txt)
2
+
3
+ # Web framework
4
+ fastapi>=0.111.0
5
+ uvicorn[standard]>=0.29.0
6
+
7
+ # Async PostgreSQL
8
+ sqlalchemy[asyncio]>=2.0.0
9
+ asyncpg>=0.29.0
10
+
11
+ # Settings management
12
+ pydantic-settings>=2.0.0
13
+
14
+ # Auth
15
+ python-jose[cryptography]>=3.3.0
16
+ passlib[bcrypt]>=1.7.4
17
+ python-multipart>=0.0.9 # required by FastAPI for form/file uploads
18
+ email-validator>=2.1.0 # required by pydantic EmailStr
19
+
20
+ # MinIO S3-compatible storage
21
+ minio>=7.2.0
22
+ anyio>=4.0.0 # async thread offload for sync MinIO SDK