Release v0.13: Typography V3 (Safe mode) & Endnote Extraction Fix
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
from bs4 import BeautifulSoup, NavigableString
|
||||
|
||||
from src.common.data_model import BookStructure, ManifestEntry
|
||||
from src.common.utils import setup_logger
|
||||
|
||||
logger = setup_logger("backfill_engine")
|
||||
|
||||
class BackfillEngine:
|
||||
"""
|
||||
Applies translations back to the BookStructure.
|
||||
Now simplified to only inject 'translated_html' or fallback to text.
|
||||
No format restoration logic here.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# No more Restorer or LLM Client
|
||||
pass
|
||||
|
||||
async def backfill(self, structure: BookStructure, manifest_entries: List[ManifestEntry], mode: str = "bilingual") -> BookStructure:
|
||||
"""
|
||||
Modifies the BookStructure in-place with translations.
|
||||
|
||||
Args:
|
||||
structure: The BookStructure (from book_structure.json).
|
||||
manifest_entries: List of translations.
|
||||
mode: 'bilingual' or 'target_only'.
|
||||
"""
|
||||
logger.info(f"Backfilling with mode: {mode}")
|
||||
|
||||
# Index manifest
|
||||
manifest_map: Dict[str, Dict[str, ManifestEntry]] = {}
|
||||
for entry in manifest_entries:
|
||||
if not entry.translated_text:
|
||||
continue
|
||||
|
||||
if entry.file_path not in manifest_map:
|
||||
manifest_map[entry.file_path] = {}
|
||||
manifest_map[entry.file_path][entry.element_id] = entry
|
||||
|
||||
# Iterate resources
|
||||
for item_id, resource in structure.resources.items():
|
||||
if resource.media_type != "application/xhtml+xml" or resource.href not in manifest_map:
|
||||
continue
|
||||
|
||||
file_entries = manifest_map[resource.href]
|
||||
if not file_entries:
|
||||
continue
|
||||
|
||||
logger.debug(f"Processing {resource.href} with {len(file_entries)} translations")
|
||||
|
||||
soup = BeautifulSoup(resource.content, 'html.parser')
|
||||
modified = False
|
||||
|
||||
for element_id, entry in file_entries.items():
|
||||
element = soup.find(id=element_id)
|
||||
if not element:
|
||||
logger.warning(f"Element {element_id} not found in {resource.href}")
|
||||
continue
|
||||
|
||||
# Determine content to inject
|
||||
# Prefer translated_html (rich text), fallback to translated_text (plain text)
|
||||
html_content = entry.translated_html
|
||||
plain_text = entry.translated_text
|
||||
|
||||
# Create translated tag
|
||||
new_tag = soup.new_tag(element.name)
|
||||
|
||||
if html_content:
|
||||
# Parse HTML fragment
|
||||
# Wrap in div to handle multiple top-level nodes
|
||||
inner_soup = BeautifulSoup(f"<div>{html_content}</div>", 'html.parser')
|
||||
container = inner_soup.find('div')
|
||||
if container:
|
||||
for child in list(container.children):
|
||||
new_tag.append(child)
|
||||
else:
|
||||
new_tag.string = plain_text
|
||||
else:
|
||||
# Fallback to plain text
|
||||
new_tag.string = plain_text
|
||||
|
||||
# Copy attributes
|
||||
# Copy classes and add 'translation' or 'translation-no-indent'
|
||||
classes = element.get('class', [])
|
||||
if isinstance(classes, str):
|
||||
classes = classes.split()
|
||||
|
||||
# Heuristic: Check for Drop Cap / No-Indent indicators
|
||||
# Keywords: 'drop', 'first', 'no-indent', 'noindent'
|
||||
# REMOVED: 'chapter', 'start', 'opener' (too broad, catches titles)
|
||||
special_keywords = ['drop', 'no-indent', 'noindent']
|
||||
|
||||
# 'first' can be risky (e.g. 'first-title').
|
||||
# Be conservative: match 'first' only if combined with 'para' or 'letter' or similar?
|
||||
# For now, let's keep 'first' but be aware.
|
||||
# Actually, user said minimal change. Let's stick to 'drop' and explicit 'no-indent'.
|
||||
# Checking 'calibre1' etc is useless.
|
||||
|
||||
is_special_para = any(k in c.lower() for k in special_keywords for c in classes)
|
||||
|
||||
if is_special_para:
|
||||
new_tag['class'] = classes + ['translation-no-indent']
|
||||
else:
|
||||
new_tag['class'] = classes + ['translation']
|
||||
|
||||
# Copy style
|
||||
style = element.get('style')
|
||||
if style:
|
||||
new_tag['style'] = style
|
||||
|
||||
# Injection Strategy
|
||||
if mode == "bilingual":
|
||||
element.insert_after(new_tag)
|
||||
else:
|
||||
element.replace_with(new_tag)
|
||||
|
||||
modified = True
|
||||
|
||||
if modified:
|
||||
resource.content = str(soup)
|
||||
|
||||
return structure
|
||||
@@ -0,0 +1,39 @@
|
||||
/* Bilingual Translation Overrides */
|
||||
|
||||
/* --- Common Base --- */
|
||||
.translation,
|
||||
.translation-no-indent {
|
||||
/* text-align: justify; <-- REMOVED: Restore original alignment (left/center/justify) */
|
||||
line-height: 1.6;
|
||||
clear: both;
|
||||
/* Fix overlap with floating elements like Drop Caps */
|
||||
margin-top: 0.5em;
|
||||
/* Ensure separation from previous block */
|
||||
display: block;
|
||||
/* Ensure block formatting context */
|
||||
}
|
||||
|
||||
/* --- Standard Indent (2 chars) --- */
|
||||
p.translation {
|
||||
text-indent: 2em;
|
||||
}
|
||||
|
||||
/* --- No Indent (For Drop Caps) --- */
|
||||
p.translation-no-indent {
|
||||
text-indent: 0 !important;
|
||||
/* REMOVED: margin/padding resets that broke centering */
|
||||
}
|
||||
|
||||
/* --- Fix Drop Cap Font Mismatch --- */
|
||||
.translation span,
|
||||
.translation-no-indent span,
|
||||
.translation b,
|
||||
.translation-no-indent b,
|
||||
.translation i,
|
||||
.translation-no-indent i,
|
||||
.translation em,
|
||||
.translation-no-indent em,
|
||||
.translation strong,
|
||||
.translation-no-indent strong {
|
||||
font-family: inherit !important;
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from ebooklib import epub
|
||||
from src.common.data_model import BookStructure
|
||||
from src.common.utils import setup_logger, ensure_directory
|
||||
|
||||
logger = setup_logger("bilingual_builder")
|
||||
|
||||
class BilingualBuilder:
|
||||
"""
|
||||
Assembles the final EPUB from BookStructure.
|
||||
|
||||
Preserves the original TOC structure by reading it from the original EPUB.
|
||||
"""
|
||||
|
||||
def __init__(self, work_dir: Path, original_epub_path: Path = None):
|
||||
self.work_dir = work_dir
|
||||
self.assets_dir = work_dir / "assets"
|
||||
self.original_epub_path = original_epub_path
|
||||
self._original_book = None
|
||||
|
||||
def _load_original_book(self):
|
||||
"""Lazy load original book for TOC extraction."""
|
||||
if self._original_book is None and self.original_epub_path and self.original_epub_path.exists():
|
||||
try:
|
||||
self._original_book = epub.read_epub(str(self.original_epub_path))
|
||||
logger.debug(f"Loaded original EPUB for TOC: {self.original_epub_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load original EPUB: {e}")
|
||||
return self._original_book
|
||||
|
||||
def _sanitize_toc(self, toc):
|
||||
"""
|
||||
Ensure all TOC nodes have IDs (for ebooklib compatibility).
|
||||
From v0.08 bilingual_builder.py
|
||||
"""
|
||||
result = []
|
||||
for item in toc:
|
||||
if isinstance(item, (epub.Link, epub.Section)):
|
||||
if not getattr(item, 'uid', None):
|
||||
item.uid = f"navPoint-{uuid.uuid4().hex[:8]}"
|
||||
result.append(item)
|
||||
elif isinstance(item, tuple) and len(item) == 2:
|
||||
# Handle (Section, [children]) structure
|
||||
section, children = item
|
||||
if isinstance(section, (epub.Link, epub.Section)):
|
||||
if not getattr(section, 'uid', None):
|
||||
section.uid = f"navPoint-{uuid.uuid4().hex[:8]}"
|
||||
sanitized_children = self._sanitize_toc(children)
|
||||
result.append((section, sanitized_children))
|
||||
else:
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
def _validate_and_fix_toc(self, toc, book_items):
|
||||
"""
|
||||
Recursively validate and fix TOC links.
|
||||
Removes nodes with broken links that cannot be fixed.
|
||||
"""
|
||||
fixed_toc = []
|
||||
for item in toc:
|
||||
if isinstance(item, (epub.Link, epub.Section)):
|
||||
# Check href
|
||||
href = getattr(item, 'href', '')
|
||||
if href:
|
||||
# Remove anchor for check
|
||||
clean_href = href.split('#')[0]
|
||||
# Check if item exists in book (by file_name)
|
||||
found = False
|
||||
for existing_item in book_items.values():
|
||||
if existing_item.file_name == clean_href:
|
||||
found = True
|
||||
break
|
||||
if clean_href.endswith(existing_item.file_name) or existing_item.file_name.endswith(clean_href):
|
||||
item.href = existing_item.file_name
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
if 'c0.xhtml' in clean_href:
|
||||
for existing_item in book_items.values():
|
||||
if 'titlepage' in existing_item.file_name or 'cover' in existing_item.file_name.lower():
|
||||
if existing_item.media_type == "application/xhtml+xml":
|
||||
logger.info(f"Fixed TOC link: {href} -> {existing_item.file_name}")
|
||||
item.href = existing_item.file_name
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
logger.warning(f"Removing broken TOC link: {href}")
|
||||
continue
|
||||
|
||||
if isinstance(item, tuple) and len(item) == 2:
|
||||
section, children = item
|
||||
fixed_children = self._validate_and_fix_toc(children, book_items)
|
||||
fixed_toc.append((section, fixed_children))
|
||||
else:
|
||||
fixed_toc.append(item)
|
||||
|
||||
return fixed_toc
|
||||
|
||||
def build(self, structure: BookStructure, output_path: Path) -> Path:
|
||||
"""
|
||||
Builds the EPUB file.
|
||||
Returns the path to the generated EPUB.
|
||||
"""
|
||||
logger.info(f"Building final EPUB: {output_path}")
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
book = epub.EpubBook()
|
||||
|
||||
# 1. Metadata
|
||||
book.set_identifier(structure.metadata.identifier or f"uuid-{uuid.uuid4().hex[:12]}")
|
||||
book.set_title(structure.metadata.title)
|
||||
book.set_language(structure.metadata.language)
|
||||
book.add_author(structure.metadata.author)
|
||||
|
||||
# 2. Copy TOC from original EPUB if available
|
||||
original_book = self._load_original_book()
|
||||
if original_book and hasattr(original_book, 'toc') and original_book.toc:
|
||||
book.toc = self._sanitize_toc(original_book.toc)
|
||||
logger.info("Copied TOC structure from original EPUB")
|
||||
|
||||
|
||||
|
||||
# 3. Add Resources - First pass: Collect CSS items
|
||||
items_map = {} # id -> epub_item
|
||||
css_items = [] # List of CSS EpubItem for linking
|
||||
html_items = [] # List of (item_id, EpubHtml) tuples
|
||||
|
||||
for item_id, resource in structure.resources.items():
|
||||
# Skip NCX - we'll handle it separately
|
||||
if resource.media_type == "application/x-dtbncx+xml":
|
||||
continue
|
||||
|
||||
if resource.media_type == "application/xhtml+xml":
|
||||
# HTML Item - create but don't add yet (need to add CSS links)
|
||||
item = epub.EpubHtml(
|
||||
uid=item_id,
|
||||
file_name=resource.href,
|
||||
media_type=resource.media_type,
|
||||
content=resource.content.encode('utf-8')
|
||||
)
|
||||
html_items.append((item_id, item))
|
||||
elif resource.file_path:
|
||||
# Binary/Asset Item
|
||||
asset_full_path = self.work_dir / resource.file_path
|
||||
|
||||
if not asset_full_path.exists():
|
||||
logger.warning(f"Asset missing: {asset_full_path}")
|
||||
continue
|
||||
|
||||
with open(asset_full_path, 'rb') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check if this is the cover image
|
||||
if structure.metadata.cover_image_id == item_id:
|
||||
logger.info(f"Setting cover image: {item_id}")
|
||||
# Manually add cover metadata to avoid "set_cover" creating a duplicate item with hardcoded ID
|
||||
# 1. Add item normally (as EpubImage)
|
||||
item = epub.EpubImage(
|
||||
uid=item_id,
|
||||
file_name=resource.href,
|
||||
media_type=resource.media_type,
|
||||
content=content
|
||||
)
|
||||
book.add_item(item)
|
||||
items_map[item_id] = item
|
||||
|
||||
# 2. Add metadata pointing to it
|
||||
book.add_metadata(None, 'meta', item_id, {'name': 'cover', 'content': item_id})
|
||||
continue
|
||||
|
||||
if "image" in resource.media_type:
|
||||
item = epub.EpubImage(
|
||||
uid=item_id,
|
||||
file_name=resource.href,
|
||||
media_type=resource.media_type,
|
||||
content=content
|
||||
)
|
||||
else:
|
||||
item = epub.EpubItem(
|
||||
uid=item_id,
|
||||
file_name=resource.href,
|
||||
media_type=resource.media_type,
|
||||
content=content
|
||||
)
|
||||
# Track CSS items
|
||||
if resource.media_type == "text/css":
|
||||
css_items.append(item)
|
||||
|
||||
book.add_item(item)
|
||||
items_map[item_id] = item
|
||||
else:
|
||||
logger.warning(f"Skipping resource {item_id}: No content or file path.")
|
||||
continue
|
||||
|
||||
# 3.1 Inject Bilingual Style CSS
|
||||
bilingual_css_item = None
|
||||
try:
|
||||
# We assume the CSS file is in the same directory as this builder.py
|
||||
# Or use package resources. simpler: relative to file
|
||||
css_source_path = Path(__file__).parent / "bilingual_style.css"
|
||||
if css_source_path.exists():
|
||||
with open(css_source_path, "rb") as f:
|
||||
css_content = f.read()
|
||||
|
||||
bilingual_css_item = epub.EpubItem(
|
||||
uid="bilingual_style_css",
|
||||
file_name="Styles/bilingual_style.css",
|
||||
media_type="text/css",
|
||||
content=css_content
|
||||
)
|
||||
book.add_item(bilingual_css_item)
|
||||
css_items.append(bilingual_css_item) # Add to list so it gets linked
|
||||
logger.info("Injected bilingual_style.css")
|
||||
else:
|
||||
logger.warning(f"bilingual_style.css not found at {css_source_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to inject bilingual CSS: {e}")
|
||||
|
||||
# 4. Add HTML items with CSS links
|
||||
for item_id, item in html_items:
|
||||
html_dir = Path(item.file_name).parent
|
||||
for css_item in css_items:
|
||||
css_path = Path(css_item.file_name)
|
||||
# Calculate relative path from HTML directory to CSS file
|
||||
try:
|
||||
relative_css_path = Path(css_path).relative_to(html_dir)
|
||||
except ValueError:
|
||||
# Not a subpath, calculate full relative
|
||||
# Go up from html_dir, then down to css_path
|
||||
up_count = len(html_dir.parts)
|
||||
# Handle case where html_dir is empty (root)
|
||||
if str(html_dir) == ".":
|
||||
prefix = ""
|
||||
else:
|
||||
prefix = "../" * up_count
|
||||
|
||||
relative_css_path = Path(f"{prefix}{css_path}")
|
||||
|
||||
item.add_link(href=str(relative_css_path), rel='stylesheet', type='text/css')
|
||||
book.add_item(item)
|
||||
items_map[item_id] = item
|
||||
|
||||
# 5. Copy missing items from original EPUB (cover, etc.)
|
||||
# This ensures TOC links don't break
|
||||
if original_book:
|
||||
added_hrefs = {item.file_name for item in items_map.values() if hasattr(item, 'file_name')}
|
||||
|
||||
for orig_item in original_book.get_items():
|
||||
orig_name = orig_item.get_name()
|
||||
|
||||
# Check for existence (exact or suffix overlap to handle EPUB/OEBPS prefixes)
|
||||
is_duplicate = False
|
||||
if orig_name in added_hrefs:
|
||||
is_duplicate = True
|
||||
else:
|
||||
for added in added_hrefs:
|
||||
if added.endswith(orig_name) or orig_name.endswith(added):
|
||||
is_duplicate = True
|
||||
break
|
||||
|
||||
if is_duplicate:
|
||||
continue
|
||||
|
||||
# Skip NCX and NAV - we generate these
|
||||
if 'ncx' in orig_name.lower() or orig_name.endswith('nav.xhtml'):
|
||||
continue
|
||||
|
||||
# Double check if it's an image we likely already processed
|
||||
if orig_item.media_type and orig_item.media_type.startswith("image/"):
|
||||
# If we didn't match via filename, verify if we missed it or if it's truly new
|
||||
# But suffix check usually catches it.
|
||||
pass
|
||||
|
||||
# Copy the item directly
|
||||
try:
|
||||
book.add_item(orig_item)
|
||||
items_map[orig_item.id] = orig_item
|
||||
logger.debug(f"Copied missing item from original: {orig_name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to copy item {orig_name}: {e}")
|
||||
|
||||
# 6. Spine
|
||||
book.spine = []
|
||||
for item_id in structure.spine:
|
||||
if item_id in items_map:
|
||||
book.spine.append(items_map[item_id])
|
||||
else:
|
||||
logger.warning(f"Spine item {item_id} not found in resources.")
|
||||
|
||||
# Add missing spine items from original
|
||||
if original_book:
|
||||
for spine_id, _ in original_book.spine:
|
||||
if spine_id not in [i.id for i in book.spine]:
|
||||
orig_item = original_book.get_item_with_id(spine_id)
|
||||
if orig_item and orig_item.id in items_map:
|
||||
book.spine.append(items_map[orig_item.id])
|
||||
|
||||
# Validate and fix TOC
|
||||
if original_book and hasattr(book, 'toc') and book.toc:
|
||||
try:
|
||||
book.toc = self._validate_and_fix_toc(book.toc, items_map)
|
||||
logger.info("Validated and fixed TOC links")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to validate TOC: {e}")
|
||||
|
||||
# 7. Navigation - NCX and Nav
|
||||
book.add_item(epub.EpubNcx())
|
||||
book.add_item(epub.EpubNav())
|
||||
|
||||
# 7. Write
|
||||
epub.write_epub(str(output_path), book)
|
||||
logger.info(f"EPUB created successfully at {output_path}")
|
||||
|
||||
return output_path
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import re
|
||||
from typing import Dict, Tuple, List, Optional
|
||||
from loguru import logger
|
||||
from src.common.utils import setup_logger
|
||||
|
||||
logger = setup_logger("format_restorer")
|
||||
|
||||
class FormatRestorer:
|
||||
"""
|
||||
Restores HTML formatting from placeholders.
|
||||
"""
|
||||
|
||||
PLACEHOLDER_REGEX = re.compile(r'φ(/?\d+)φ')
|
||||
|
||||
def restore(self, text_with_placeholders: str, placeholder_map: Dict[str, str], context_id: str = None) -> Tuple[str, bool]:
|
||||
"""
|
||||
Restores HTML from text with placeholders.
|
||||
Returns (restored_html, success).
|
||||
"""
|
||||
if not placeholder_map:
|
||||
return text_with_placeholders or "", True
|
||||
|
||||
if not text_with_placeholders:
|
||||
prefix = placeholder_map.get("_prefix", "")
|
||||
suffix = placeholder_map.get("_suffix", "")
|
||||
return prefix + suffix, True
|
||||
|
||||
prefix = placeholder_map.get("_prefix", "")
|
||||
suffix = placeholder_map.get("_suffix", "")
|
||||
|
||||
inner_map = {k: v for k, v in placeholder_map.items() if not k.startswith("_")}
|
||||
|
||||
found_ids = set(self.PLACEHOLDER_REGEX.findall(text_with_placeholders))
|
||||
expected_ids = set(inner_map.keys())
|
||||
|
||||
success = True
|
||||
context_msg = f" [ID: ...{context_id[-6:]}]" if context_id else ""
|
||||
|
||||
missing_ids = expected_ids - found_ids
|
||||
if missing_ids:
|
||||
logger.warning(f"Restoration warning: missing placeholders {missing_ids}{context_msg}")
|
||||
success = False
|
||||
|
||||
unknown_ids = found_ids - expected_ids
|
||||
if unknown_ids:
|
||||
real_unknowns = set()
|
||||
for pid in unknown_ids:
|
||||
if pid.startswith('/') and pid[1:] in expected_ids:
|
||||
continue
|
||||
real_unknowns.add(pid)
|
||||
|
||||
if real_unknowns:
|
||||
logger.warning(f"Restoration warning: unknown placeholders {real_unknowns}{context_msg}")
|
||||
success = False
|
||||
|
||||
def replace_match(match):
|
||||
pid = match.group(1)
|
||||
if pid in inner_map:
|
||||
return inner_map[pid]
|
||||
else:
|
||||
return ""
|
||||
|
||||
try:
|
||||
restored_inner = self.PLACEHOLDER_REGEX.sub(replace_match, text_with_placeholders)
|
||||
restored_html = prefix + restored_inner + suffix
|
||||
return restored_html, success
|
||||
except Exception as e:
|
||||
logger.error(f"Restoration failed: {e}")
|
||||
return prefix + self._strip_placeholders(text_with_placeholders) + suffix, False
|
||||
|
||||
def _strip_placeholders(self, text: str) -> str:
|
||||
return self.PLACEHOLDER_REGEX.sub("", text)
|
||||
@@ -0,0 +1,106 @@
|
||||
import asyncio
|
||||
from typing import List, Optional
|
||||
from loguru import logger
|
||||
|
||||
from src.common.data_model import ManifestEntry
|
||||
from src.assembly.format_restorer import FormatRestorer
|
||||
from src.translation.llm_client import LLMClient
|
||||
from src.common.utils import add_spacing
|
||||
|
||||
class RestorationEngine:
|
||||
"""
|
||||
Handles post-translation format restoration:
|
||||
1. Spacing optimization (Pangu)
|
||||
2. HTML tag restoration (FormatRestorer)
|
||||
3. LLM-based repair (if restoration fails)
|
||||
"""
|
||||
|
||||
def __init__(self, llm_client: Optional[LLMClient] = None):
|
||||
self.restorer = FormatRestorer()
|
||||
self.llm_client = llm_client
|
||||
|
||||
async def restore_entries(self, entries: List[ManifestEntry], force: bool = False) -> int:
|
||||
"""
|
||||
Process a list of entries, updating their translated_html field.
|
||||
Returns the number of successfully restored entries.
|
||||
"""
|
||||
success_count = 0
|
||||
|
||||
for entry in entries:
|
||||
if not entry.translated_text:
|
||||
continue
|
||||
|
||||
# Idempotency check: Skip if already restored (unless forced)
|
||||
if entry.translated_html and not force:
|
||||
success_count += 1
|
||||
continue
|
||||
|
||||
# 1. Spacing Fix
|
||||
spaced_text = add_spacing(entry.translated_text)
|
||||
|
||||
# 2. Format Restoration
|
||||
restored_html, success = self.restorer.restore(
|
||||
spaced_text,
|
||||
entry.placeholders,
|
||||
context_id=entry.entry_id
|
||||
)
|
||||
|
||||
# 3. LLM Repair (if needed and available)
|
||||
if not success and self.llm_client:
|
||||
logger.debug(f"Attempting LLM repair for {entry.entry_id}...")
|
||||
repaired_text = await self._repair_placeholders(entry, spaced_text)
|
||||
if repaired_text:
|
||||
# Retry with repaired text
|
||||
repaired_html, repaired_success = self.restorer.restore(
|
||||
repaired_text,
|
||||
entry.placeholders,
|
||||
context_id=f"{entry.entry_id}-REPAIR"
|
||||
)
|
||||
if repaired_success:
|
||||
logger.info(f"LLM Repair successful for {entry.entry_id}")
|
||||
restored_html = repaired_html
|
||||
# We do NOT update translated_text here to preserve original LLM output?
|
||||
# Actually user might want the spaced and repaired text as the "text".
|
||||
# But let's keep translated_text as raw-ish, and translated_html as final.
|
||||
# Update: To ensure consistency, maybe we should update translated_text?
|
||||
# The implementation plan says "Save result to ManifestEntry.translated_html".
|
||||
# It implicitly leaves translated_text alone or updates it?
|
||||
# Let's keep translated_text as is (except maybe spacing? no, keep it raw).
|
||||
# But wait, if we repair key text, next time we run, we might want to use the repaired text?
|
||||
# For now, only populate translated_html.
|
||||
success = True
|
||||
else:
|
||||
logger.warning(f"LLM Repair failed validation for {entry.entry_id}")
|
||||
|
||||
# 4. Save Result
|
||||
# Even if validation failed, we often get a "best effort" restored_html from restorer (fallback).
|
||||
# The restorer usually returns *something*.
|
||||
# If completely failed (e.g. mismatch), restorer might return raw text or partial?
|
||||
# FormatRestorer.restore returns (restored_str, success_bool).
|
||||
# Even if success=False, restored_str is produced (often just stripping unused placeholders or keeping them raw).
|
||||
|
||||
entry.translated_html = restored_html
|
||||
if success:
|
||||
success_count += 1
|
||||
|
||||
return success_count
|
||||
|
||||
async def _repair_placeholders(self, entry: ManifestEntry, current_text: str) -> Optional[str]:
|
||||
"""Ask LLM to fix placeholders."""
|
||||
try:
|
||||
system_prompt = "You are a translation repair assistant."
|
||||
user_prompt = f"""
|
||||
The following translation has incorrect placeholders.
|
||||
Please fix the placeholders in the Translated text so they match the Original text structure EXACTLY.
|
||||
Do NOT change the Chinese translation content, only fix the φXφ tags.
|
||||
|
||||
Original: {entry.original_text}
|
||||
Translated (Broken): {current_text}
|
||||
|
||||
Output ONLY the fixed Translated text.
|
||||
"""
|
||||
repaired = await self.llm_client.raw_chat_completion(system_prompt, user_prompt)
|
||||
return repaired.strip()
|
||||
except Exception as e:
|
||||
logger.error(f"LLM Repair error: {e}")
|
||||
return None
|
||||
Reference in New Issue
Block a user