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

+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)