Cnass commited on
Commit
072a1f2
·
verified ·
1 Parent(s): 60367fd

Upload 3 files

Browse files
Files changed (3) hide show
  1. app/config.py +18 -0
  2. app/main.py +153 -1
  3. app/translation.py +24 -5
app/config.py CHANGED
@@ -43,6 +43,24 @@ PROMPT_FORMAT = os.environ.get("PROMPT_FORMAT", "").strip().lower()
43
  if not PROMPT_FORMAT:
44
  PROMPT_FORMAT = "rosetta" if "rosetta" in MODEL_REPO.lower() else "hy-mt2"
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  LLAMA_SERVER_HOST = os.environ.get("LLAMA_SERVER_HOST", "127.0.0.1")
47
  LLAMA_SERVER_PORT = _int("LLAMA_SERVER_PORT", 8080)
48
  LLAMA_SERVER_URL = f"http://{LLAMA_SERVER_HOST}:{LLAMA_SERVER_PORT}"
 
43
  if not PROMPT_FORMAT:
44
  PROMPT_FORMAT = "rosetta" if "rosetta" in MODEL_REPO.lower() else "hy-mt2"
45
 
46
+ # Where uploaded RPG Maker projects live. Prefers the Space's persistent
47
+ # volume when one is mounted: a job that survives a restart is the difference
48
+ # between resuming a half-finished game and re-translating it on GPU time.
49
+ _DEFAULT_PROJECT_DIR = (
50
+ "/data/projects"
51
+ if os.path.isdir("/data") and os.access("/data", os.W_OK)
52
+ else "/app/projects"
53
+ )
54
+ PROJECT_DIR = os.environ.get("PROJECT_DIR", _DEFAULT_PROJECT_DIR)
55
+
56
+ # Uploaded projects are deleted this many hours after their last update, so a
57
+ # few full games do not quietly fill the disk.
58
+ PROJECT_RETENTION_HOURS = _int("PROJECT_RETENTION_HOURS", 24)
59
+
60
+ # Hard cap on an uploaded zip. A www/data folder is a few MB even for a long
61
+ # game; anything far past that is a whole packaged game, not the data folder.
62
+ MAX_UPLOAD_MB = _int("MAX_UPLOAD_MB", 200)
63
+
64
  LLAMA_SERVER_HOST = os.environ.get("LLAMA_SERVER_HOST", "127.0.0.1")
65
  LLAMA_SERVER_PORT = _int("LLAMA_SERVER_PORT", 8080)
66
  LLAMA_SERVER_URL = f"http://{LLAMA_SERVER_HOST}:{LLAMA_SERVER_PORT}"
app/main.py CHANGED
@@ -2,12 +2,14 @@ import asyncio
2
  import time
3
  from pathlib import Path
4
 
5
- from fastapi import Depends, FastAPI, Header, HTTPException
6
  from fastapi.middleware.cors import CORSMiddleware
 
7
  from fastapi.staticfiles import StaticFiles
8
  from pydantic import BaseModel, Field
9
 
10
  from . import config, glossary as glossary_store, grouping, llama_client
 
11
  from .translation import LANGUAGES, build_messages, is_translatable
12
 
13
  app = FastAPI(title="Translation API", version="2.0.0")
@@ -294,6 +296,140 @@ async def translate_document(req: DocumentTranslateRequest):
294
  )
295
 
296
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  @app.on_event("startup")
298
  async def load_glossary():
299
  count = glossary_store.load(config.GLOSSARY_FILE)
@@ -301,6 +437,22 @@ async def load_glossary():
301
  print(f"[startup] glossary: {count} terms from {config.GLOSSARY_FILE}")
302
 
303
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  # Registered last so it can't shadow the API routes above: Starlette matches
305
  # routes in registration order, and a "/" mount would otherwise catch
306
  # everything not matched yet.
 
2
  import time
3
  from pathlib import Path
4
 
5
+ from fastapi import Depends, FastAPI, File, Header, HTTPException, UploadFile
6
  from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.responses import FileResponse
8
  from fastapi.staticfiles import StaticFiles
9
  from pydantic import BaseModel, Field
10
 
11
  from . import config, glossary as glossary_store, grouping, llama_client
12
+ from .rpgmaker import jobs as rpg_jobs, store as rpg_store
13
  from .translation import LANGUAGES, build_messages, is_translatable
14
 
15
  app = FastAPI(title="Translation API", version="2.0.0")
 
296
  )
297
 
298
 
299
+ # ---------------------------------------------------------------------------
300
+ # RPG Maker MV/MZ projects
301
+ # ---------------------------------------------------------------------------
302
+
303
+
304
+ def _status_payload(meta: dict) -> dict:
305
+ total = meta.get("total_units", 0) or 0
306
+ done = meta.get("translated_units", 0) or 0
307
+ files = [
308
+ {
309
+ "file": name,
310
+ "total": stats["total"],
311
+ "done": stats["done"],
312
+ "percent": round(100 * stats["done"] / stats["total"], 1)
313
+ if stats["total"]
314
+ else 100.0,
315
+ }
316
+ for name, stats in sorted(meta.get("files", {}).items())
317
+ ]
318
+ return {
319
+ "id": meta["id"],
320
+ "name": meta.get("name"),
321
+ "engine": meta.get("engine"),
322
+ "status": meta.get("status"),
323
+ "target_lang": meta.get("target_lang"),
324
+ "total_units": total,
325
+ "total_slots": meta.get("total_slots", 0),
326
+ "translated_units": done,
327
+ "percent": round(100 * done / total, 1) if total else 0.0,
328
+ "review_count": meta.get("review_count", 0),
329
+ "review_samples": meta.get("review_samples", []),
330
+ "unreadable_files": meta.get("unreadable_files", []),
331
+ "running": rpg_jobs.active_project_id() == meta["id"],
332
+ "files": files,
333
+ "error": meta.get("error"),
334
+ }
335
+
336
+
337
+ def _require_project(project_id: str) -> dict:
338
+ meta = rpg_store.load_meta(project_id)
339
+ if meta is None:
340
+ raise HTTPException(status_code=404, detail="Unknown project")
341
+ return meta
342
+
343
+
344
+ @app.post("/project/upload", dependencies=[Depends(verify_api_key)])
345
+ async def upload_project(file: UploadFile = File(...)):
346
+ """Accept a zipped RPG Maker data folder and index its dialogue."""
347
+ rpg_store.purge_expired()
348
+ payload = await file.read()
349
+ try:
350
+ meta = await asyncio.to_thread(
351
+ rpg_store.create, payload, file.filename or "project.zip"
352
+ )
353
+ except rpg_store.UploadError as exc:
354
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
355
+ return _status_payload(meta)
356
+
357
+
358
+ @app.get("/project", dependencies=[Depends(verify_api_key)])
359
+ async def list_projects():
360
+ return {
361
+ "projects": [_status_payload(m) for m in rpg_store.list_projects()],
362
+ "running": rpg_jobs.active_project_id(),
363
+ "retention_hours": config.PROJECT_RETENTION_HOURS,
364
+ }
365
+
366
+
367
+ @app.get("/project/{project_id}/status", dependencies=[Depends(verify_api_key)])
368
+ async def project_status(project_id: str):
369
+ return _status_payload(_require_project(project_id))
370
+
371
+
372
+ class StartTranslationRequest(BaseModel):
373
+ target_lang: str = "tr"
374
+
375
+
376
+ @app.post("/project/{project_id}/start", dependencies=[Depends(verify_api_key)])
377
+ async def start_project(project_id: str, req: StartTranslationRequest):
378
+ meta = _require_project(project_id)
379
+ if not await llama_client.health():
380
+ raise HTTPException(
381
+ status_code=503,
382
+ detail="The model is still loading. Try again in a moment.",
383
+ )
384
+ try:
385
+ await rpg_jobs.start(project_id, req.target_lang)
386
+ except RuntimeError as exc:
387
+ # One GPU, one job: say which project is holding it.
388
+ raise HTTPException(
389
+ status_code=409,
390
+ detail=f"{exc} (project {rpg_jobs.active_project_id()})",
391
+ ) from exc
392
+ meta["status"] = rpg_store.STATUS_TRANSLATING
393
+ meta["target_lang"] = req.target_lang
394
+ return _status_payload(meta)
395
+
396
+
397
+ @app.post("/project/{project_id}/cancel", dependencies=[Depends(verify_api_key)])
398
+ async def cancel_project(project_id: str):
399
+ _require_project(project_id)
400
+ if rpg_jobs.active_project_id() != project_id:
401
+ raise HTTPException(status_code=409, detail="That project is not running")
402
+ rpg_jobs.cancel()
403
+ return {"cancelling": True}
404
+
405
+
406
+ @app.get("/project/{project_id}/download", dependencies=[Depends(verify_api_key)])
407
+ async def download_project(project_id: str):
408
+ """Rebuild the data folder with whatever is translated so far.
409
+
410
+ Deliberately available before the run finishes: untranslated strings keep
411
+ their source text, so a partial download is always a playable game rather
412
+ than a broken one.
413
+ """
414
+ meta = _require_project(project_id)
415
+ output = await asyncio.to_thread(rpg_store.build_output, project_id, meta)
416
+ stem = Path(meta.get("name") or project_id).stem
417
+ return FileResponse(
418
+ output,
419
+ media_type="application/zip",
420
+ filename=f"{stem}-{meta.get('target_lang') or 'translated'}.zip",
421
+ )
422
+
423
+
424
+ @app.delete("/project/{project_id}", dependencies=[Depends(verify_api_key)])
425
+ async def delete_project(project_id: str):
426
+ if rpg_jobs.active_project_id() == project_id:
427
+ rpg_jobs.cancel()
428
+ if not rpg_store.delete(project_id):
429
+ raise HTTPException(status_code=404, detail="Unknown project")
430
+ return {"deleted": project_id}
431
+
432
+
433
  @app.on_event("startup")
434
  async def load_glossary():
435
  count = glossary_store.load(config.GLOSSARY_FILE)
 
437
  print(f"[startup] glossary: {count} terms from {config.GLOSSARY_FILE}")
438
 
439
 
440
+ @app.on_event("startup")
441
+ async def resume_translation_jobs():
442
+ """Pick up a run the last shutdown interrupted, and drop stale uploads.
443
+
444
+ The Space sleeps; without this a project that was 90% translated would sit
445
+ at 90% forever and have to be restarted by hand.
446
+ """
447
+ removed = rpg_store.purge_expired()
448
+ if removed:
449
+ print(f"[startup] purged {len(removed)} expired project(s)")
450
+ await rpg_jobs.resume_pending()
451
+ resumed = rpg_jobs.active_project_id()
452
+ if resumed:
453
+ print(f"[startup] resumed translation of project {resumed}")
454
+
455
+
456
  # Registered last so it can't shadow the API routes above: Starlette matches
457
  # routes in registration order, and a "/" mount would otherwise catch
458
  # everything not matched yet.
app/translation.py CHANGED
@@ -69,13 +69,22 @@ _GROUP_CLAUSE = (
69
  )
70
 
71
 
72
- # Control codes and format specifiers that carry no translatable words:
73
- # RPG Maker escapes (\SE[1], \V[1], \n[1], \C[3], and bare \. \| \^ \> \<),
74
- # printf specifiers (%1, %s, %d) and template variables ({var}, ${var}).
 
 
 
 
 
 
 
75
  _CONTROL_CODE_RE = re.compile(
76
- r"\\[A-Za-z]+\[[^\]]*\]"
77
  r"|\\[A-Za-z]+"
78
- r"|\\[.|^><!$]"
 
 
79
  r"|%\d+|%[sdif]"
80
  r"|\$\{[^}]*\}"
81
  r"|\{\{[^}]*\}\}"
@@ -83,6 +92,16 @@ _CONTROL_CODE_RE = re.compile(
83
  )
84
 
85
 
 
 
 
 
 
 
 
 
 
 
86
  def is_translatable(text: str) -> bool:
87
  """False for strings that are only control codes, numbers or punctuation.
88
 
 
69
  )
70
 
71
 
72
+ # Control codes and format specifiers that carry no translatable words.
73
+ # Covers the RPG Maker MV/MZ escape set - bracketed codes (\N[1] actor name,
74
+ # \V[1] variable, \C[3] colour, \I[5] icon, \P[1] party member, \FS[24] font
75
+ # size, \PX[8]/\PY[8] positioning, \f[Actor1] face, \SE[1]), the bare escapes
76
+ # (\G currency, \FB/\FI bold-italic, \. \| \! \^ \> \< \$ \\ \{ \}), the
77
+ # message-window markup tags plugins add (<WordWrap>, <center>, ...), plus
78
+ # printf specifiers (%1, %s) and template variables ({var}, ${var}).
79
+ #
80
+ # Ordering matters: the bracketed form has to be tried before the bare-letter
81
+ # form, otherwise "\C[2]" would match as "\C" and leave a stray "[2]" behind.
82
  _CONTROL_CODE_RE = re.compile(
83
+ r"\\[A-Za-z]{1,4}\[[^\]]*\]"
84
  r"|\\[A-Za-z]+"
85
+ r"|\\[.|^><!$\\{}]"
86
+ r"|<(?:WordWrap|wordwrap|clear|indent|left|center|right)>"
87
+ r"|\[\[[^\[\]]*\]\]"
88
  r"|%\d+|%[sdif]"
89
  r"|\$\{[^}]*\}"
90
  r"|\{\{[^}]*\}\}"
 
92
  )
93
 
94
 
95
+ def control_codes(text: str) -> list[str]:
96
+ """Every control code in `text`, in order.
97
+
98
+ Used to verify a translation kept the codes the source had: RPG Maker will
99
+ happily render a corrupted "\\C[2" or silently lose a "\\N[1]", and a
100
+ dropped code is the difference between a working line and a broken one.
101
+ """
102
+ return _CONTROL_CODE_RE.findall(text)
103
+
104
+
105
  def is_translatable(text: str) -> bool:
106
  """False for strings that are only control codes, numbers or punctuation.
107