plan291037 commited on
Commit
e96865c
·
verified ·
1 Parent(s): 8ea9feb

Update backend/lens_core.py

Browse files
Files changed (1) hide show
  1. backend/lens_core.py +138 -112
backend/lens_core.py CHANGED
@@ -187,94 +187,115 @@ AI_MODEL_ALIASES = {
187
  }
188
  }
189
 
190
- AI_PROMPT_SYSTEM_BASE = (
191
- "You are a professional manga translator and dialogue localizer.\n"
192
- "Rewrite each paragraph as natural dialogue in the target language while preserving meaning, tone, intent, and character voice.\n"
193
- "Keep lines concise for speech bubbles. Do not add new information. Do not omit meaning. Do not explain.\n"
194
- "Preserve emphasis (… ! ?). Avoid excessive punctuation.\n"
195
- "If the input is already in the target language, improve it (dialogue polish) without changing meaning."
196
- )
197
 
198
- AI_LANG_STYLE = {
199
- "th": (
200
- "Target language: Thai\\n"
201
- "Write Thai manga dialogue that reads like a high-quality Thai scanlation: natural, concise, and in-character.\\n"
202
- "Keep lines short for speech bubbles; avoid stiff, literal phrasing.\\n"
203
- "Default: omit pronouns and omit gendered polite sentence-final particles unless the source line clearly requires them.\\n"
204
- "Never use a male-coded second-person pronoun. When addressing someone by name, do not add a second-person pronoun after the name; prefer NAME + clause.\\n"
205
- "If a second-person reference is unavoidable, use a neutral/casual form appropriate to tone, but keep it gender-neutral and consistent with the line.\\n"
206
- "Use particles/interjections sparingly to match tone; do not overuse.\\n"
207
- "Keep names/terms consistent; transliterate when appropriate.\\n"
208
- "Output only the translated text."
209
- ),
210
- "en": (
211
- "Target language: English\n"
212
- "Write natural English manga dialogue: concise, conversational, with contractions where natural.\n"
213
- "Localize tone and character voice; keep emotion and emphasis.\n"
214
- "Keep proper nouns consistent; do not over-explain."
215
- ),
216
- "ja": (
217
- "Target language: Japanese\n"
218
- "Write natural Japanese manga dialogue: concise, spoken.\n"
219
- "Choose 丁寧語/タメ口 to match context; keep emotion and emphasis.\n"
220
- "Keep proper nouns consistent; keep SFX natural in Japanese."
221
- ),
222
- "default": (
223
- "Write natural manga dialogue in the target language: concise, spoken, faithful to meaning and tone."
224
- ),
225
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
 
227
- AI_PROMPT_USER_BY_LANG = {
228
- "th": """Thai manga translation guidelines (OCR input)
229
-
230
- Goal: Produce Thai text that reads like a skilled Thai manga translator: natural, concise, and faithful to tone/intent, without guessing wildly.
231
-
232
- A) Identify the type of text and translate accordingly
233
- - Narration / inner monologue: smooth Thai narration, natural flow.
234
- - Spoken dialogue: real spoken Thai, short and punchy for speech bubbles.
235
- - Labels / status / announcements / UI text: short, clear, list-like formatting when appropriate.
236
-
237
- B) Character voice & register
238
- - Match intensity (calm / angry / teasing / rude) but do not add extra rudeness that is not present.
239
- - Use particles/interjections only when they help the voice; do not overuse.
240
- - Keep SFX / elongated sounds manga-like (elongation, repetition) but not excessively long.
241
-
242
- C) Addressing, pronouns, and gendered endings
243
- - Default: omit pronouns and omit gendered polite sentence-final particles unless the source line clearly requires them.
244
- - Never use a male-coded second-person pronoun.
245
- - When a line addresses someone by name, keep the name and write the sentence without inserting a second-person pronoun after the name. Prefer: NAME + sentence.
246
- - If a second-person reference is truly needed for readability, pick a neutral/casual option appropriate to tone, and keep it gender-neutral; do not guess gender from the name alone.
247
- - Do not guess speaker gender. Only use clearly gendered first-person forms or gendered sentence endings when the same source line strongly signals them. Keep consistency within the line and never mix conflicting forms.
248
-
249
- D) OCR noise / incomplete words (be conservative)
250
- - OCR may drop/swap letters or insert duplicates. Fix ONLY when it is high-confidence and obvious (1–2 characters off and the intended word is clear).
251
- - Do not “correct” words that already look valid. Do not over-correct names, terms, or stylistic spellings.
252
- - If uncertain, keep the original token or transliterate; do not invent a different word.
253
-
254
- E) Proper nouns & recurring terms
255
- - Keep character names, places, skills, and key terms consistent across the page.
256
- - Preserve honorifics only when present and meaningful.
257
-
258
- Do not add explanations. Return only the translated Thai text, preserving paragraph boundaries and order.""".strip(),
259
- "en": """Style preferences:
260
- - Keep English dialogue concise and conversational.
261
- - Keep lines short for speech bubbles.
262
- - Keep names and recurring terms consistent.
263
- - Keep SFX short; avoid very long repeated characters.
264
- """.strip(),
265
- "ja": """Style preferences:
266
- - Keep Japanese dialogue concise and natural for manga.
267
- - Keep lines short for speech bubbles.
268
- - Keep names and recurring terms consistent.
269
- - Keep SFX short; avoid very long repeated characters.
270
- """.strip(),
271
- "default": """Style preferences:
272
- - Keep dialogue concise, spoken, and faithful to tone.
273
- - Keep lines short for speech bubbles.
274
- - Keep names and recurring terms consistent.
275
- - Keep SFX short; avoid very long repeated characters.
276
- """.strip(),
277
- }
278
 
279
  AI_PROMPT_RESPONSE_CONTRACT_JSON = (
280
  "Return ONLY valid JSON (no markdown, no extra text).\n"
@@ -312,9 +333,10 @@ _FONT_PAIR_CACHE = {}
312
  _TP_HTML_EPS_PX = 0.0
313
  ZWSP = "\u200b"
314
 
315
- def ai_prompt_user_default(lang: str) -> str:
316
  l = _normalize_lang(lang)
317
- return (AI_PROMPT_USER_BY_LANG.get(l) or AI_PROMPT_USER_BY_LANG.get("default") or "").strip()
 
318
 
319
  def _active_ai_contract() -> str:
320
  return AI_PROMPT_RESPONSE_CONTRACT_JSON if DO_AI_JSON else AI_PROMPT_RESPONSE_CONTRACT_TEXT
@@ -421,22 +443,22 @@ def _save_ai_cache(path: str, cache: dict):
421
  json.dump(cache, f, ensure_ascii=False)
422
  os.replace(tmp, path)
423
 
424
- def _build_ai_prompt_packet(target_lang: str, original_text_full: str):
425
  lang = _normalize_lang(target_lang)
426
- input_json = json.dumps(
427
- {"target_lang": lang, "originalTextFull": original_text_full}, ensure_ascii=False)
428
  output_schema = json.dumps({"aiTextFull": "..."}, ensure_ascii=False)
429
  data_template = _active_ai_data_template()
430
  if DO_AI_JSON:
431
- data_text = data_template.format(
432
- input_json=input_json, output_schema=output_schema)
433
  else:
434
  data_text = data_template.format(input_json=input_json)
435
 
436
- style = AI_LANG_STYLE.get(lang) or AI_LANG_STYLE.get("default") or ""
437
- editable = (ai_prompt_user_default(lang) or "").strip()
 
438
 
439
- system_parts = [AI_PROMPT_SYSTEM_BASE]
440
  if style:
441
  system_parts.append(style)
442
  system_parts.append(_active_ai_contract())
@@ -448,6 +470,7 @@ def _build_ai_prompt_packet(target_lang: str, original_text_full: str):
448
  user_parts.append(data_text)
449
  return system_text, user_parts
450
 
 
451
  def _gemini_generate_json(api_key: str, model: str, system_text: str, user_parts: list[str]):
452
  url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
453
  parts = [{"text": p} for p in user_parts if (p or "").strip()]
@@ -479,6 +502,7 @@ def _gemini_generate_json(api_key: str, model: str, system_text: str, user_parts
479
  raise Exception("Gemini returned empty text")
480
  return txt
481
 
 
482
  def _read_first_env(*names: str) -> str:
483
  for n in names:
484
  v = (os.environ.get(n) or "").strip()
@@ -1522,15 +1546,20 @@ def ai_translate_original_text(original_text_full: str, target_lang: str):
1522
  if not api_key:
1523
  raise Exception("AI_API_KEY is required for AI translation")
1524
 
 
1525
  lang = _normalize_lang(target_lang)
 
 
 
 
1526
  prompt_sig = _sha1(
1527
  json.dumps(
1528
  {
1529
- "sys": AI_PROMPT_SYSTEM_BASE,
1530
- "edit": AI_PROMPT_USER_BY_LANG,
1531
  "contract": _active_ai_contract(),
1532
  "data": _active_ai_data_template(),
1533
- "style": AI_LANG_STYLE.get(lang) or AI_LANG_STYLE.get("default") or "",
1534
  },
1535
  ensure_ascii=False,
1536
  )
@@ -1542,8 +1571,7 @@ def ai_translate_original_text(original_text_full: str, target_lang: str):
1542
  cache = _load_ai_cache(AI_CACHE_PATH)
1543
  cache_key = _sha1(
1544
  json.dumps(
1545
- {"provider": provider, "m": model, "u": base_url,
1546
- "l": lang, "p": prompt_sig, "t": original_text_full},
1547
  ensure_ascii=False,
1548
  )
1549
  )
@@ -1552,9 +1580,9 @@ def ai_translate_original_text(original_text_full: str, target_lang: str):
1552
  if lang == "th" and cached:
1553
  t = str(cached.get("aiTextFull") or "")
1554
  if t:
1555
- t2 = re.sub(r"(?:(?<=^)|(?<=[\s\"'“”‘’()\[\]{}<>]))\u0e19\u0e32\u0e22(?=(?:\s|$))", "", t)
1556
- t2 = re.sub(r"[ \t]{2,}", " ", t2)
1557
- t2 = re.sub(r"^[ \t]+", "", t2, flags=re.MULTILINE)
1558
  if t2 != t:
1559
  cached = dict(cached)
1560
  cached["aiTextFull"] = t2
@@ -1562,7 +1590,7 @@ def ai_translate_original_text(original_text_full: str, target_lang: str):
1562
  _save_ai_cache(AI_CACHE_PATH, cache)
1563
  return cached
1564
 
1565
- system_text, user_parts = _build_ai_prompt_packet(lang, original_text_full)
1566
 
1567
  started = time.time()
1568
  used_model = model
@@ -1571,16 +1599,14 @@ def ai_translate_original_text(original_text_full: str, target_lang: str):
1571
  elif provider == "anthropic":
1572
  raw = _anthropic_generate_json(api_key, model, system_text, user_parts)
1573
  else:
1574
- raw, used_model = _openai_compat_generate_json(
1575
- api_key, base_url, model, system_text, user_parts)
1576
 
1577
- ai_text_full = _parse_ai_textfull_only(
1578
- raw) if DO_AI_JSON else _parse_ai_textfull_text_only(raw)
1579
 
1580
  if lang == "th" and ai_text_full:
1581
- ai_text_full = re.sub(r"(?:(?<=^)|(?<=[\s\"'“”‘’()\[\]{}<>]))\u0e19\u0e32\u0e22(?=(?:\s|$))", "", ai_text_full)
1582
- ai_text_full = re.sub(r"[ \t]{2,}", " ", ai_text_full)
1583
- ai_text_full = re.sub(r"^[ \t]+", "", ai_text_full, flags=re.MULTILINE)
1584
 
1585
  result = {
1586
  "aiTextFull": ai_text_full,
 
187
  }
188
  }
189
 
190
+ AI_PROMPT_SYSTEM_BASE = ""
 
 
 
 
 
 
191
 
192
+ AI_LANG_STYLE = {"default": ""}
193
+
194
+ AI_PROMPT_USER_BY_LANG = {"default": ""}
195
+
196
+ TP_REMOTE_DEFAULTS_URL = (
197
+ os.environ.get("TP_REMOTE_DEFAULTS_URL")
198
+ or "https://raw.githubusercontent.com/Kuju29/TextPhantomOCR_Overlay/refs/heads/main/defaults_api.json"
199
+ ).strip()
200
+ TP_REMOTE_DEFAULTS_TIMEOUT_SEC = float(os.environ.get("TP_REMOTE_DEFAULTS_TIMEOUT_SEC", "2"))
201
+
202
+
203
+ def _remote_defaults() -> dict:
204
+ url = TP_REMOTE_DEFAULTS_URL
205
+ if not url:
206
+ raise RuntimeError("TP_REMOTE_DEFAULTS_URL is required")
207
+
208
+ if url.startswith("file://"):
209
+ with open(url[len("file://"):], "r", encoding="utf-8") as f:
210
+ raw = f.read()
211
+ else:
212
+ with httpx.Client(timeout=TP_REMOTE_DEFAULTS_TIMEOUT_SEC) as client:
213
+ r = client.get(
214
+ url,
215
+ headers={"accept": "application/json"},
216
+ follow_redirects=True,
217
+ )
218
+ r.raise_for_status()
219
+ raw = r.text
220
+
221
+ data = json.loads((raw or "").strip() or "{}")
222
+ if not isinstance(data, dict) or not data:
223
+ raise RuntimeError("Remote defaults is empty")
224
+ return data
225
+
226
+
227
+ def _remote_first_str(data: dict, *keys: str) -> str:
228
+ if not data:
229
+ return ""
230
+ for k in keys:
231
+ v = data.get(k)
232
+ if isinstance(v, str) and v.strip():
233
+ return v.strip()
234
+ return ""
235
+
236
+
237
+ def _remote_first_map(data: dict, *keys: str) -> dict:
238
+ if not data:
239
+ return {}
240
+ for k in keys:
241
+ v = data.get(k)
242
+ if isinstance(v, dict) and v:
243
+ return v
244
+ return {}
245
+
246
+
247
+ def ai_prompt_system_base(data: dict | None = None) -> str:
248
+ d = data if isinstance(data, dict) else _remote_defaults()
249
+ v = _remote_first_str(
250
+ d,
251
+ "AI_PROMPT_SYSTEM_BASE",
252
+ "aiPromptSystemBase",
253
+ "promptSystemBase",
254
+ "systemBase",
255
+ )
256
+ if not v:
257
+ raise RuntimeError("Missing AI_PROMPT_SYSTEM_BASE in remote defaults")
258
+ return v
259
+
260
+
261
+ def ai_lang_style_map(data: dict | None = None) -> dict[str, str]:
262
+ d = data if isinstance(data, dict) else _remote_defaults()
263
+ remote = _remote_first_map(d, "AI_LANG_STYLE", "aiLangStyle", "langStyle")
264
+ if not remote:
265
+ raise RuntimeError("Missing AI_LANG_STYLE in remote defaults")
266
+ out: dict[str, str] = {}
267
+ for k, v in remote.items():
268
+ if not isinstance(k, str) or not isinstance(v, str):
269
+ continue
270
+ kk = _normalize_lang(k)
271
+ if not kk:
272
+ continue
273
+ out[kk] = v.strip()
274
+ out.setdefault("default", "")
275
+ return out
276
+
277
+
278
+ def ai_prompt_user_by_lang_map(data: dict | None = None) -> dict[str, str]:
279
+ d = data if isinstance(data, dict) else _remote_defaults()
280
+ remote = _remote_first_map(
281
+ d,
282
+ "AI_PROMPT_USER_BY_LANG",
283
+ "aiPromptUserByLang",
284
+ "promptUserByLang",
285
+ )
286
+ if not remote:
287
+ raise RuntimeError("Missing AI_PROMPT_USER_BY_LANG in remote defaults")
288
+ out: dict[str, str] = {}
289
+ for k, v in remote.items():
290
+ if not isinstance(k, str) or not isinstance(v, str):
291
+ continue
292
+ kk = _normalize_lang(k)
293
+ if not kk:
294
+ continue
295
+ out[kk] = v.strip()
296
+ out.setdefault("default", "")
297
+ return out
298
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
 
300
  AI_PROMPT_RESPONSE_CONTRACT_JSON = (
301
  "Return ONLY valid JSON (no markdown, no extra text).\n"
 
333
  _TP_HTML_EPS_PX = 0.0
334
  ZWSP = "\u200b"
335
 
336
+ def ai_prompt_user_default(lang: str, data: dict | None = None) -> str:
337
  l = _normalize_lang(lang)
338
+ m = ai_prompt_user_by_lang_map(data)
339
+ return (m.get(l) or m.get("default") or "").strip()
340
 
341
  def _active_ai_contract() -> str:
342
  return AI_PROMPT_RESPONSE_CONTRACT_JSON if DO_AI_JSON else AI_PROMPT_RESPONSE_CONTRACT_TEXT
 
443
  json.dump(cache, f, ensure_ascii=False)
444
  os.replace(tmp, path)
445
 
446
+ def _build_ai_prompt_packet(target_lang: str, original_text_full: str, defaults: dict | None = None):
447
  lang = _normalize_lang(target_lang)
448
+ d = defaults if isinstance(defaults, dict) else _remote_defaults()
449
+ input_json = json.dumps({"target_lang": lang, "originalTextFull": original_text_full}, ensure_ascii=False)
450
  output_schema = json.dumps({"aiTextFull": "..."}, ensure_ascii=False)
451
  data_template = _active_ai_data_template()
452
  if DO_AI_JSON:
453
+ data_text = data_template.format(input_json=input_json, output_schema=output_schema)
 
454
  else:
455
  data_text = data_template.format(input_json=input_json)
456
 
457
+ styles = ai_lang_style_map(d)
458
+ style = styles.get(lang) or styles.get("default") or ""
459
+ editable = (ai_prompt_user_default(lang, d) or "").strip()
460
 
461
+ system_parts = [ai_prompt_system_base(d)]
462
  if style:
463
  system_parts.append(style)
464
  system_parts.append(_active_ai_contract())
 
470
  user_parts.append(data_text)
471
  return system_text, user_parts
472
 
473
+
474
  def _gemini_generate_json(api_key: str, model: str, system_text: str, user_parts: list[str]):
475
  url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
476
  parts = [{"text": p} for p in user_parts if (p or "").strip()]
 
502
  raise Exception("Gemini returned empty text")
503
  return txt
504
 
505
+
506
  def _read_first_env(*names: str) -> str:
507
  for n in names:
508
  v = (os.environ.get(n) or "").strip()
 
1546
  if not api_key:
1547
  raise Exception("AI_API_KEY is required for AI translation")
1548
 
1549
+ defaults = _remote_defaults()
1550
  lang = _normalize_lang(target_lang)
1551
+ styles = ai_lang_style_map(defaults)
1552
+ edit_map = ai_prompt_user_by_lang_map(defaults)
1553
+ sys_base = ai_prompt_system_base(defaults)
1554
+
1555
  prompt_sig = _sha1(
1556
  json.dumps(
1557
  {
1558
+ "sys": sys_base,
1559
+ "edit": edit_map,
1560
  "contract": _active_ai_contract(),
1561
  "data": _active_ai_data_template(),
1562
+ "style": styles.get(lang) or styles.get("default") or "",
1563
  },
1564
  ensure_ascii=False,
1565
  )
 
1571
  cache = _load_ai_cache(AI_CACHE_PATH)
1572
  cache_key = _sha1(
1573
  json.dumps(
1574
+ {"provider": provider, "m": model, "u": base_url, "l": lang, "p": prompt_sig, "t": original_text_full},
 
1575
  ensure_ascii=False,
1576
  )
1577
  )
 
1580
  if lang == "th" and cached:
1581
  t = str(cached.get("aiTextFull") or "")
1582
  if t:
1583
+ t2 = re.sub(r"(?:(?<=^)|(?<=[\s\"'“”‘’()\[\]{}<>]))นาย(?=(?:\s|$))", "", t)
1584
+ t2 = re.sub(r"[ ]{2,}", " ", t2)
1585
+ t2 = re.sub(r"^[ ]+", "", t2, flags=re.MULTILINE)
1586
  if t2 != t:
1587
  cached = dict(cached)
1588
  cached["aiTextFull"] = t2
 
1590
  _save_ai_cache(AI_CACHE_PATH, cache)
1591
  return cached
1592
 
1593
+ system_text, user_parts = _build_ai_prompt_packet(lang, original_text_full, defaults)
1594
 
1595
  started = time.time()
1596
  used_model = model
 
1599
  elif provider == "anthropic":
1600
  raw = _anthropic_generate_json(api_key, model, system_text, user_parts)
1601
  else:
1602
+ raw, used_model = _openai_compat_generate_json(api_key, base_url, model, system_text, user_parts)
 
1603
 
1604
+ ai_text_full = _parse_ai_textfull_only(raw) if DO_AI_JSON else _parse_ai_textfull_text_only(raw)
 
1605
 
1606
  if lang == "th" and ai_text_full:
1607
+ ai_text_full = re.sub(r"(?:(?<=^)|(?<=[\s\"'“”‘’()\[\]{}<>]))นาย(?=(?:\s|$))", "", ai_text_full)
1608
+ ai_text_full = re.sub(r"[ ]{2,}", " ", ai_text_full)
1609
+ ai_text_full = re.sub(r"^[ ]+", "", ai_text_full, flags=re.MULTILINE)
1610
 
1611
  result = {
1612
  "aiTextFull": ai_text_full,