Initial local translation API with persistent projects and batch jobs

This commit is contained in:
mrleo1nid committed 2026-10-23 13:31:13 +03:00
commit 13492929c6
21 files changed
+1624

No files matched your search

+148
View File
@@ -0,0 +1,148 @@
from contextlib import asynccontextmanager
import asyncio
import logging
from fastapi import FastAPI, HTTPException, Query
from .config import Settings, load_settings
from .engine import Engine
from .models import Project, TranslationRequest
from .protection import BUILTIN_RULES
from .provider import Provider, ProviderError, autostart
from .storage import Storage
def create_app(settings: Settings | None = None, provider_factory=Provider):
settings = settings or load_settings()
storage = Storage(settings.database)
engine = Engine(storage, provider_factory)
startup_errors = {}
@asynccontextmanager
async def lifespan(app):
with storage.exclusive_worker():
for name, config in settings.providers.items():
if config.autostart:
try:
await autostart(config)
except ProviderError as exc:
startup_errors[name] = str(exc)
logging.getLogger(__name__).warning("%s: %s", name, exc)
engine.start()
try:
yield
finally:
await engine.stop()
app = FastAPI(title="Local Translator", version="0.1.0", lifespan=lifespan,
description="Local translation projects, glossary, durable jobs and protected placeholders.")
app.state.storage = storage
app.state.engine = engine
def project_or_404(identity):
project = storage.get_project(identity)
if not project:
raise HTTPException(404, "Project not found")
return project
def job_or_404(identity):
job = storage.get_job(identity)
if not job:
raise HTTPException(404, "Job not found")
return job
def validate_provider(project):
if project.provider not in settings.providers:
raise HTTPException(422, "Provider is not configured")
def enqueue(project_id, request):
project = project_or_404(project_id)
config = settings.providers.get(project["provider"])
if config is None:
raise HTTPException(422, "Provider is not configured")
result = storage.create_job(project, config.model_dump(), request.model_dump())
engine.wakeup.set()
return result
@app.get("/health")
async def health():
return {"status": "ok", "worker_running": engine.task is not None and not engine.task.done(), "startup_errors": startup_errors}
@app.get("/providers")
async def providers():
return {name: {"base_url": config.base_url, "model": config.model} for name, config in settings.providers.items()}
@app.get("/providers/{name}/models")
async def models(name: str):
config = settings.providers.get(name)
if config is None:
raise HTTPException(404, "Provider not found")
provider = provider_factory(config)
try:
return await provider.request("GET", "models")
except ProviderError as exc:
raise HTTPException(502, str(exc)) from exc
finally:
await provider.close()
@app.get("/protection-rules")
async def protection_rules():
return BUILTIN_RULES
@app.post("/projects", status_code=201)
async def create_project(project: Project):
validate_provider(project)
return storage.create_project(project.model_dump())
@app.get("/projects")
async def projects():
return storage.projects()
@app.get("/projects/{project_id}")
async def get_project(project_id: str):
return project_or_404(project_id)
@app.put("/projects/{project_id}")
async def replace_project(project_id: str, project: Project):
project_or_404(project_id)
validate_provider(project)
return storage.update_project(project_id, project.model_dump())
@app.post("/projects/{project_id}/jobs", status_code=202)
async def create_job(project_id: str, request: TranslationRequest):
return enqueue(project_id, request)
@app.post("/projects/{project_id}/translate")
async def translate(project_id: str, request: TranslationRequest):
job = enqueue(project_id, request)
# Bounded HTTP wait; the durable job continues after timeout/disconnect.
deadline = asyncio.get_running_loop().time() + 30
while asyncio.get_running_loop().time() < deadline:
job = storage.get_job(job["id"])
if job["status"] not in {"queued", "running"}:
return job
await asyncio.sleep(0.1)
from fastapi.responses import JSONResponse
return JSONResponse(status_code=202, content=job, headers={"Location": f"/jobs/{job['id']}"})
@app.get("/projects/{project_id}/jobs")
async def list_jobs(project_id: str, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0)):
project_or_404(project_id)
return storage.list_jobs(project_id, limit, offset)
@app.get("/jobs/{job_id}")
async def get_job(job_id: str):
return job_or_404(job_id)
@app.post("/jobs/{job_id}/retry", status_code=202)
async def retry_job(job_id: str):
job = job_or_404(job_id)
if job["status"] in {"queued", "running"}:
raise HTTPException(409, "Job is already queued or running")
if job["status"] == "completed":
raise HTTPException(409, "All items are already completed")
storage.retry(job_id)
engine.wakeup.set()
return storage.get_job(job_id)
return app