feat: complete translator stack

This commit is contained in:
truetype committed 2026-01-26 18:15:46 +01:00
commit 6fe7c5121b
9946 files changed
+1747495

No files matched your search

Binary file not shown.
+15
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
from . import session
+25
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Collects processor entrypoints for EPUB/PDF/DOCX/TXT."""
+14
View File
@@ -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)
+28
View File
@@ -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)
+15
View File
@@ -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
+40
View File
@@ -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
+22
View File
@@ -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)
+10
View File
@@ -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)
+18
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
from . import health, translation
+7
View File
@@ -0,0 +1,7 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
async def health_check():
return {"status": "ok", "service": "traduttore"}
+223
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
from .chunking import chunk_document, chunk_store
+87
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Helpers for ETA, config, and state tracking will live here."""
Binary file not shown.
Binary file not shown.
+15
View File
@@ -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")
+10
View File
@@ -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
+37
View File
@@ -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()
+23
View File
@@ -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()