feat: complete translator stack
This commit is contained in:
commit
6fe7c5121b
9946 files changed
+1747495
No files matched your search
@@ -0,0 +1,10 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY backend/requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend ./backend
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
from fastapi import FastAPI
|
||||
from backend.app.routers import health, translation
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(translation.router, prefix="/api")
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
print("Avvio del servizio di traduzione")
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
await translation.ollama_client.close()
|
||||
@@ -0,0 +1 @@
|
||||
from . import session
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SessionState(BaseModel):
|
||||
session_id: str
|
||||
file_title: str
|
||||
text_title: Optional[str]
|
||||
source_lang: str
|
||||
target_lang: str
|
||||
total_chunks: int
|
||||
current_chunk_index: int
|
||||
status: str
|
||||
started_at: Optional[datetime] = None
|
||||
ended_at: Optional[datetime] = None
|
||||
last_chunk_at: Optional[datetime] = None
|
||||
eta_seconds: Optional[int] = None
|
||||
chunks_remaining: Optional[int] = None
|
||||
stop_requested: bool = False
|
||||
error_message: Optional[str] = None
|
||||
translated_text: str = ""
|
||||
translated_chunks: List[str] = Field(default_factory=list)
|
||||
final_document_path: Optional[str] = None
|
||||
@@ -0,0 +1 @@
|
||||
"""Collects processor entrypoints for EPUB/PDF/DOCX/TXT."""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
|
||||
from backend.app.processors.models import DocumentExtraction
|
||||
from backend.app.processors.utils import normalize_paragraphs
|
||||
|
||||
|
||||
def process_docx(path: Path) -> DocumentExtraction:
|
||||
doc = Document(path)
|
||||
title = doc.core_properties.title or None
|
||||
raw_text = "\n\n".join(p.text for p in doc.paragraphs if p.text.strip())
|
||||
paragraphs = normalize_paragraphs(raw_text)
|
||||
return DocumentExtraction(title=title, paragraphs=paragraphs)
|
||||
@@ -0,0 +1,28 @@
|
||||
from html import unescape
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from ebooklib import ITEM_DOCUMENT, epub
|
||||
|
||||
from backend.app.processors.models import DocumentExtraction
|
||||
from backend.app.processors.utils import normalize_paragraphs
|
||||
|
||||
HTML_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _strip_tags(source: str) -> str:
|
||||
return unescape(HTML_TAG_RE.sub("", source))
|
||||
|
||||
|
||||
def process_epub(path: Path) -> DocumentExtraction:
|
||||
book = epub.read_epub(path)
|
||||
title_meta = book.get_metadata("DC", "title")
|
||||
title = title_meta[0][0] if title_meta else None
|
||||
|
||||
paragraphs: list[str] = []
|
||||
for item in book.get_items_of_type(ITEM_DOCUMENT):
|
||||
html = item.get_content().decode("utf-8", errors="ignore")
|
||||
text = _strip_tags(html)
|
||||
paragraphs.extend(normalize_paragraphs(text))
|
||||
|
||||
return DocumentExtraction(title=title, paragraphs=paragraphs)
|
||||
@@ -0,0 +1,15 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
@dataclass
|
||||
class DocumentExtraction:
|
||||
title: Optional[str]
|
||||
paragraphs: List[str]
|
||||
source_language: Optional[str] = None
|
||||
|
||||
@dataclass
|
||||
class DocumentChunk:
|
||||
index: int
|
||||
text: str
|
||||
title_hint: Optional[str]
|
||||
paragraph_count: int
|
||||
@@ -0,0 +1,40 @@
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import fitz
|
||||
from pdf2image import convert_from_path
|
||||
import pytesseract
|
||||
|
||||
from backend.app.processors.models import DocumentExtraction
|
||||
from backend.app.processors.utils import normalize_paragraphs
|
||||
|
||||
|
||||
def process_pdf(path: Path) -> DocumentExtraction:
|
||||
paragraphs = _extract_text_pages(path)
|
||||
if not paragraphs:
|
||||
paragraphs = _extract_via_ocr(path)
|
||||
return DocumentExtraction(title=None, paragraphs=paragraphs)
|
||||
|
||||
|
||||
def _extract_text_pages(path: Path) -> List[str]:
|
||||
doc = fitz.open(path)
|
||||
collected = []
|
||||
for page in doc:
|
||||
text = page.get_text("text")
|
||||
if text.strip():
|
||||
collected.extend(normalize_paragraphs(text))
|
||||
return collected
|
||||
|
||||
|
||||
def _extract_via_ocr(path: Path) -> List[str]:
|
||||
try:
|
||||
images = convert_from_path(str(path), dpi=200)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
paragraphs = []
|
||||
for image in images:
|
||||
text = pytesseract.image_to_string(image)
|
||||
if text.strip():
|
||||
paragraphs.extend(normalize_paragraphs(text))
|
||||
return paragraphs
|
||||
@@ -0,0 +1,22 @@
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from backend.app.processors.models import DocumentExtraction
|
||||
from backend.app.processors.docx_processor import process_docx
|
||||
from backend.app.processors.epub_processor import process_epub
|
||||
from backend.app.processors.pdf_processor import process_pdf
|
||||
from backend.app.processors.txt_processor import process_txt
|
||||
|
||||
ProcessorFn = Callable[[Path], DocumentExtraction]
|
||||
|
||||
PROCESSORS: dict[str, ProcessorFn] = {
|
||||
".txt": process_txt,
|
||||
".docx": process_docx,
|
||||
".pdf": process_pdf,
|
||||
".epub": process_epub,
|
||||
}
|
||||
|
||||
|
||||
def select_processor(path: Path) -> Optional[ProcessorFn]:
|
||||
suffix = path.suffix.lower()
|
||||
return PROCESSORS.get(suffix)
|
||||
@@ -0,0 +1,10 @@
|
||||
from pathlib import Path
|
||||
|
||||
from backend.app.processors.models import DocumentExtraction
|
||||
from backend.app.processors.utils import normalize_paragraphs
|
||||
|
||||
|
||||
def process_txt(path: Path) -> DocumentExtraction:
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
paragraphs = normalize_paragraphs(text)
|
||||
return DocumentExtraction(title=path.stem, paragraphs=paragraphs)
|
||||
@@ -0,0 +1,18 @@
|
||||
import re
|
||||
from typing import List
|
||||
|
||||
PARAGRAPH_SPLIT_RE = re.compile(r"\n{2,}")
|
||||
|
||||
|
||||
def normalize_paragraphs(source: str) -> List[str]:
|
||||
text = source.replace("\r\n", "\n").strip()
|
||||
if not text:
|
||||
return []
|
||||
|
||||
paragraphs = [p.strip() for p in PARAGRAPH_SPLIT_RE.split(text)]
|
||||
normalized = [p for p in paragraphs if p]
|
||||
return normalized
|
||||
|
||||
|
||||
def merge_paragraphs(paragraphs: List[str]) -> str:
|
||||
return "\n\n".join(paragraphs)
|
||||
@@ -0,0 +1 @@
|
||||
from . import health, translation
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "ok", "service": "traduttore"}
|
||||
@@ -0,0 +1,223 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, UploadFile
|
||||
from langdetect import DetectorFactory, detect
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from backend.app.models.session import SessionState
|
||||
from backend.app.processors.registry import select_processor
|
||||
from backend.app.services.chunking import chunk_document, chunk_store
|
||||
from backend.app.utils.logger import logger
|
||||
from backend.app.utils.merge import write_final_document
|
||||
from backend.app.utils.ollama_client import OllamaClient
|
||||
from backend.app.utils.session_store import session_store
|
||||
|
||||
DetectorFactory.seed = 0
|
||||
router = APIRouter()
|
||||
OLLAMA_BASE_URL = os.getenv("OLLAMA_URL", "https://ollama.solarpi.net")
|
||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "translategemma:latest")
|
||||
ollama_client = OllamaClient(OLLAMA_BASE_URL, OLLAMA_MODEL)
|
||||
|
||||
|
||||
def _detect_language(paragraphs: List[str]) -> str:
|
||||
snippet = " ".join(paragraphs[:5])
|
||||
if not snippet.strip():
|
||||
return "auto"
|
||||
try:
|
||||
return detect(snippet)
|
||||
except Exception:
|
||||
return "auto"
|
||||
|
||||
|
||||
async def _save_upload(upload: UploadFile) -> Path:
|
||||
suffix = Path(upload.filename or "").suffix or ".bin"
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||
payload = await upload.read()
|
||||
tmp.write(payload)
|
||||
tmp.flush()
|
||||
return Path(tmp.name)
|
||||
|
||||
|
||||
def _build_session(session_id: str, file_title: str, extraction_title: str, source_lang: str, target_lang: str, chunk_count: int) -> SessionState:
|
||||
return SessionState(
|
||||
session_id=session_id,
|
||||
file_title=file_title,
|
||||
text_title=extraction_title or file_title,
|
||||
source_lang=source_lang,
|
||||
target_lang=target_lang,
|
||||
total_chunks=chunk_count,
|
||||
current_chunk_index=0,
|
||||
status="pending",
|
||||
chunks_remaining=chunk_count,
|
||||
)
|
||||
|
||||
|
||||
async def _run_translation(session_id: str) -> None:
|
||||
session = session_store.get(session_id)
|
||||
if not session:
|
||||
return
|
||||
|
||||
session.status = "running"
|
||||
session.started_at = datetime.utcnow()
|
||||
session_store.update(session)
|
||||
logger.info("Sessione %s: avvio traduzione", session_id)
|
||||
|
||||
chunks = chunk_store.get_chunks(session_id)
|
||||
durations: List[float] = []
|
||||
|
||||
for chunk in chunks:
|
||||
session = session_store.get(session_id)
|
||||
if not session:
|
||||
return
|
||||
if session.stop_requested:
|
||||
session.status = "stopped"
|
||||
session_store.update(session)
|
||||
logger.info("Sessione %s: interrotta dall'utente", session_id)
|
||||
return
|
||||
|
||||
start = datetime.utcnow()
|
||||
try:
|
||||
translation = await ollama_client.translate(chunk.text, session.source_lang, session.target_lang)
|
||||
except Exception as exc:
|
||||
session.status = "error"
|
||||
session.error_message = str(exc)
|
||||
session_store.update(session)
|
||||
logger.error("Sessione %s chunk %s fallito: %s", session_id, chunk.index, exc)
|
||||
return
|
||||
|
||||
now = datetime.utcnow()
|
||||
duration = (now - start).total_seconds()
|
||||
durations.append(duration)
|
||||
if len(durations) > 3:
|
||||
durations.pop(0)
|
||||
|
||||
session.translated_chunks.append(translation)
|
||||
session.translated_text = "\n\n".join(session.translated_chunks)
|
||||
session.current_chunk_index = chunk.index
|
||||
session.chunks_remaining = max(0, session.total_chunks - chunk.index)
|
||||
session.last_chunk_at = now
|
||||
avg = sum(durations) / len(durations) if durations else 0
|
||||
session.eta_seconds = int(avg * session.chunks_remaining) if session.chunks_remaining else 0
|
||||
session_store.update(session)
|
||||
logger.info("Sessione %s: chunk %s tradotto", session_id, chunk.index)
|
||||
|
||||
session.status = "completed"
|
||||
session.ended_at = datetime.utcnow()
|
||||
session.chunks_remaining = 0
|
||||
session.eta_seconds = 0
|
||||
session.final_document_path = str(write_final_document(session.session_id, session.translated_text))
|
||||
logger.info("Sessione %s completata. Documento finale: %s", session_id, session.final_document_path)
|
||||
session_store.update(session)
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_document(target_lang: str = Form(...), file: UploadFile = File(...)):
|
||||
processor = select_processor(Path(file.filename or ""))
|
||||
if not processor:
|
||||
raise HTTPException(status_code=400, detail="Formato file non supportato")
|
||||
|
||||
temp_path = await _save_upload(file)
|
||||
try:
|
||||
extraction = processor(temp_path)
|
||||
finally:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
language = extraction.source_language or _detect_language(extraction.paragraphs)
|
||||
session_id = uuid4().hex
|
||||
chunks = chunk_document(session_id, extraction)
|
||||
logger.info("Sessione %s: %s chunk generati", session_id, len(chunks))
|
||||
|
||||
session = _build_session(
|
||||
session_id,
|
||||
file.filename or "sconosciuto",
|
||||
extraction.title,
|
||||
language,
|
||||
target_lang,
|
||||
len(chunks),
|
||||
)
|
||||
session_store.create(session)
|
||||
|
||||
return {
|
||||
"session_id": session.session_id,
|
||||
"total_chunks": session.total_chunks,
|
||||
"source_lang": session.source_lang,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
async def start_translation(session_id: str, background: BackgroundTasks):
|
||||
session = session_store.get(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Sessione non trovata")
|
||||
|
||||
if session.status == "running":
|
||||
return {"message": "Traduzione già in corso", "session_id": session_id}
|
||||
|
||||
session.status = "running"
|
||||
session.stop_requested = False
|
||||
session_store.update(session)
|
||||
logger.info("Sessione %s: start richiesta", session_id)
|
||||
background.add_task(_run_translation, session_id)
|
||||
|
||||
return {"message": "Traduzione avviata", "session_id": session_id}
|
||||
|
||||
|
||||
@router.post("/stop")
|
||||
async def stop_translation(session_id: str):
|
||||
session = session_store.get(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Sessione non trovata")
|
||||
|
||||
session.stop_requested = True
|
||||
session.status = "stopping"
|
||||
session_store.update(session)
|
||||
logger.info("Sessione %s: stop richiesto", session_id)
|
||||
return {"message": "Stop richiesto", "session_id": session_id}
|
||||
|
||||
|
||||
@router.get("/status/{session_id}")
|
||||
async def translation_status(session_id: str):
|
||||
session = session_store.get(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Sessione non trovata")
|
||||
return session
|
||||
|
||||
|
||||
async def _sse_generator(session_id: str):
|
||||
while True:
|
||||
session = session_store.get(session_id)
|
||||
if not session:
|
||||
break
|
||||
payload = {
|
||||
"status": session.status,
|
||||
"current_chunk": session.current_chunk_index,
|
||||
"total_chunks": session.total_chunks,
|
||||
"chunks_remaining": session.chunks_remaining,
|
||||
"eta_seconds": session.eta_seconds,
|
||||
"translated_text": session.translated_text,
|
||||
"error_message": session.error_message,
|
||||
"file_title": session.file_title,
|
||||
"text_title": session.text_title,
|
||||
"started_at": session.started_at.isoformat() if session.started_at else None,
|
||||
"ended_at": session.ended_at.isoformat() if session.ended_at else None,
|
||||
}
|
||||
yield f"data: {json.dumps(payload)}\n\n"
|
||||
if session.status in {"completed", "error", "stopped"}:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
@router.get("/stream/{session_id}")
|
||||
async def translation_stream(session_id: str):
|
||||
if not session_store.get(session_id):
|
||||
raise HTTPException(status_code=404, detail="Sessione non trovata")
|
||||
headers = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
||||
return StreamingResponse(_sse_generator(session_id), media_type="text/event-stream", headers=headers)
|
||||
@@ -0,0 +1 @@
|
||||
from .chunking import chunk_document, chunk_store
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,87 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from backend.app.processors.models import DocumentChunk, DocumentExtraction
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionChunks:
|
||||
extraction: DocumentExtraction
|
||||
chunks: List[DocumentChunk]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ChunkStore:
|
||||
def __init__(self):
|
||||
self._sessions: Dict[str, SessionChunks] = {}
|
||||
|
||||
def register(self, session_id: str, extraction: DocumentExtraction, chunks: List[DocumentChunk]) -> None:
|
||||
self._sessions[session_id] = SessionChunks(extraction, chunks, datetime.utcnow())
|
||||
|
||||
def get_chunks(self, session_id: str) -> List[DocumentChunk]:
|
||||
session = self._sessions.get(session_id)
|
||||
return session.chunks if session else []
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[SessionChunks]:
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
def remove(self, session_id: str) -> None:
|
||||
self._sessions.pop(session_id, None)
|
||||
|
||||
|
||||
chunk_store = ChunkStore()
|
||||
|
||||
|
||||
def _looks_like_title(paragraph: str) -> bool:
|
||||
stripped = paragraph.strip()
|
||||
if not stripped:
|
||||
return False
|
||||
if stripped.startswith("#"):
|
||||
return True
|
||||
if stripped.endswith(":") and len(stripped) < 60:
|
||||
return True
|
||||
if stripped.isupper() and len(stripped) < 120:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def chunk_document(session_id: str, extraction: DocumentExtraction, chunk_size: int = 2_500) -> List[DocumentChunk]:
|
||||
paragraphs = [p.strip() for p in extraction.paragraphs if p.strip()]
|
||||
if not paragraphs:
|
||||
chunk_store.register(session_id, extraction, [])
|
||||
return []
|
||||
|
||||
chunks: List[DocumentChunk] = []
|
||||
buffer: List[str] = []
|
||||
buffer_len = 0
|
||||
current_title = extraction.title
|
||||
|
||||
def flush_chunk() -> None:
|
||||
nonlocal buffer, buffer_len, current_title
|
||||
if not buffer:
|
||||
return
|
||||
chunk_text = "\n\n".join(buffer)
|
||||
chunks.append(
|
||||
DocumentChunk(
|
||||
index=len(chunks) + 1,
|
||||
text=chunk_text,
|
||||
title_hint=current_title,
|
||||
paragraph_count=len(buffer),
|
||||
)
|
||||
)
|
||||
buffer = []
|
||||
buffer_len = 0
|
||||
|
||||
for paragraph in paragraphs:
|
||||
if _looks_like_title(paragraph):
|
||||
current_title = paragraph
|
||||
addition_len = len(paragraph)
|
||||
if buffer and buffer_len + addition_len + 2 > chunk_size:
|
||||
flush_chunk()
|
||||
buffer.append(paragraph)
|
||||
buffer_len += addition_len + 2
|
||||
|
||||
flush_chunk()
|
||||
chunk_store.register(session_id, extraction, chunks)
|
||||
return chunks
|
||||
@@ -0,0 +1 @@
|
||||
"""Helpers for ETA, config, and state tracking will live here."""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
LOG_FILE = Path("backend.log")
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(),
|
||||
logging.FileHandler(LOG_FILE, encoding="utf-8"),
|
||||
],
|
||||
)
|
||||
|
||||
logger = logging.getLogger("traduttore")
|
||||
@@ -0,0 +1,10 @@
|
||||
from pathlib import Path
|
||||
|
||||
OUTPUT_BASE = Path("_bmad-output").resolve()
|
||||
OUTPUT_BASE.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def write_final_document(session_id: str, content: str) -> Path:
|
||||
target = OUTPUT_BASE / f"translated-{session_id}.txt"
|
||||
target.write_text(content, encoding="utf-8")
|
||||
return target
|
||||
@@ -0,0 +1,37 @@
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def _build_prompt(source_lang: str, target_lang: str, text: str) -> str:
|
||||
return (
|
||||
f"Traduci dal {source_lang} al {target_lang}:\\n" f"{text}"
|
||||
)
|
||||
|
||||
|
||||
class OllamaClient:
|
||||
def __init__(self, base_url: str, model: str):
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._model = model
|
||||
self._client = httpx.AsyncClient(base_url=self._base_url, timeout=60.0)
|
||||
|
||||
async def translate(self, text: str, source_lang: str, target_lang: str) -> str:
|
||||
payload = {
|
||||
"model": self._model,
|
||||
"prompt": _build_prompt(source_lang or "auto", target_lang, text),
|
||||
"stream": False,
|
||||
}
|
||||
try:
|
||||
resp = await self._client.post("/api/chat", json=payload)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise RuntimeError(f"Errore Ollama: {exc}") from exc
|
||||
|
||||
if isinstance(body, dict):
|
||||
message = body.get("response") or ""
|
||||
return message.strip()
|
||||
return ""
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.aclose()
|
||||
@@ -0,0 +1,23 @@
|
||||
from typing import Dict, Optional
|
||||
|
||||
from backend.app.models.session import SessionState
|
||||
|
||||
|
||||
class SessionStore:
|
||||
def __init__(self):
|
||||
self._sessions: Dict[str, SessionState] = {}
|
||||
|
||||
def create(self, state: SessionState) -> None:
|
||||
self._sessions[state.session_id] = state
|
||||
|
||||
def get(self, session_id: str) -> Optional[SessionState]:
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
def update(self, state: SessionState) -> None:
|
||||
self._sessions[state.session_id] = state
|
||||
|
||||
def remove(self, session_id: str) -> None:
|
||||
self._sessions.pop(session_id, None)
|
||||
|
||||
|
||||
session_store = SessionStore()
|
||||
@@ -0,0 +1,11 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
pydantic
|
||||
httpx
|
||||
python-multipart
|
||||
langdetect
|
||||
python-docx
|
||||
PyMuPDF
|
||||
pdf2image
|
||||
pytesseract
|
||||
ebooklib
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
def test_health_check():
|
||||
resp = client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json().get("status") == "ok"
|
||||
@@ -0,0 +1,69 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import fitz
|
||||
from ebooklib import epub
|
||||
from docx import Document
|
||||
|
||||
from backend.app.processors.registry import select_processor
|
||||
from backend.app.services.chunking import chunk_document
|
||||
|
||||
|
||||
def _create_docx(path: Path) -> Path:
|
||||
doc = Document()
|
||||
doc.add_heading("Titolo principale", level=1)
|
||||
doc.add_paragraph("Primo paragrafo del documento.")
|
||||
doc.add_paragraph("Secondo paragrafo con alcune frasi.")
|
||||
doc.save(path)
|
||||
return path
|
||||
|
||||
|
||||
def _create_pdf(path: Path) -> Path:
|
||||
doc = fitz.open()
|
||||
page = doc.new_page()
|
||||
page.insert_text((72, 72), "Titolo PDF\n\nParagrafo PDF di prova.")
|
||||
doc.save(path)
|
||||
doc.close()
|
||||
return path
|
||||
|
||||
|
||||
def _create_epub(path: Path) -> Path:
|
||||
book = epub.EpubBook()
|
||||
book.set_identifier("test-123")
|
||||
book.set_title("EPUB di prova")
|
||||
book.set_language("it")
|
||||
|
||||
chapter = epub.EpubHtml(title="Capitolo", file_name="chap.xhtml")
|
||||
chapter.content = "<h1>Capitolo</h1><p>Paragrafo EPUB.</p>"
|
||||
book.add_item(chapter)
|
||||
book.toc = (epub.Link("chap.xhtml", "Capitolo", "chap"),)
|
||||
book.add_item(epub.EpubNcx())
|
||||
book.add_item(epub.EpubNav())
|
||||
epub.write_epub(str(path), book)
|
||||
return path
|
||||
|
||||
|
||||
def _create_txt(path: Path) -> Path:
|
||||
path.write_text("Titolo TXT\n\nParagrafo 1\n\nParagrafo 2", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
generator_map: dict[str, Callable[[Path], Path]] = {
|
||||
".docx": _create_docx,
|
||||
".pdf": _create_pdf,
|
||||
".epub": _create_epub,
|
||||
".txt": _create_txt,
|
||||
}
|
||||
|
||||
|
||||
def test_processor_chain(tmp_path: Path):
|
||||
for suffix, generator in generator_map.items():
|
||||
target = tmp_path / f"sample{suffix}"
|
||||
path = generator(target)
|
||||
processor = select_processor(path)
|
||||
assert processor is not None, f"Processor missing for {suffix}"
|
||||
extraction = processor(path)
|
||||
chunks = chunk_document(f"session-{suffix}", extraction)
|
||||
assert chunks, f"Nessun chunk per {suffix}"
|
||||
assert all(chunk.text.strip() for chunk in chunks)
|
||||
Reference in new issue
Block a user