Spaces:
Running
Running
| """Khôi phục xuống dòng cho chế độ dịch "Theo đoạn". | |
| Khi gom nhiều dòng gốc thành 1 chunk để model có thêm ngữ cảnh, Marian/CT2 | |
| nuốt ký tự xuống dòng → output thành một khối liền. Module này phân bổ lại bản | |
| dịch về ĐÚNG số dòng gốc theo tỉ lệ (số câu / số ký tự), giữ nguyên bố cục dòng. | |
| Thuật toán port từ Space đã chạy thật `DanVP/MoxhiMT-30-demo` (chế độ "Per chunk"): | |
| chain heading-aware → sentence-proportional → char-proportional (fallback), mỗi | |
| bước trả về đúng `len(source_lines)` dòng để ghép 1:1 với dòng gốc. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| # Câu tiếng Việt: nuốt tới dấu kết câu (kèm dấu đóng ngoặc kép) hoặc hết chuỗi. | |
| VI_SENTENCE_RE = re.compile(r".+?(?:[.!?…]+[”’\"']*|$)", re.S) | |
| # Ký tự được coi là ranh giới ngắt hợp lệ khi canh theo ký tự. | |
| VI_BREAK_CHARS = set(".!?…。!?;;,:,、”’\"'") | |
| # Dòng tiêu đề chương: "第N章/节/回/卷/部/篇". | |
| HEADING_RE = re.compile(r"^第[0-9零〇一二三四五六七八九十百千万两]+[章节回卷部篇]") | |
| def split_layout_lines(text: str) -> list[str]: | |
| """Tách dòng nhưng giữ dòng rỗng đầu/cuối và chuẩn hóa newline.""" | |
| return (text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n") | |
| def _nonblank_line_count(text: str) -> int: | |
| return sum(1 for line in split_layout_lines(text) if line.strip()) | |
| def _single_translation_layout(source_lines: list[str], target_text: str) -> str: | |
| translated = (target_text or "").strip() | |
| restored = [""] * len(source_lines) | |
| for index, source_line in enumerate(source_lines): | |
| if source_line.strip(): | |
| restored[index] = translated | |
| break | |
| return "\n".join(restored) | |
| def _layout_from_nonblank_translations( | |
| source_text: str, | |
| translations: list[str], | |
| ) -> str: | |
| translated_iter = iter(translations) | |
| output: list[str] = [] | |
| for source_line in split_layout_lines(source_text): | |
| if source_line.strip(): | |
| output.append(next(translated_iter, "")) | |
| else: | |
| output.append("") | |
| sentinel = object() | |
| if next(translated_iter, sentinel) is not sentinel: | |
| raise RuntimeError("Số dòng dịch lớn hơn số dòng nguồn không rỗng") | |
| return "\n".join(output) | |
| def _rows_from_layout(source_text: str, full_text: str) -> list[tuple[int, str, str]]: | |
| source_lines = split_layout_lines(source_text) | |
| target_lines = split_layout_lines(full_text) | |
| if len(source_lines) != len(target_lines): | |
| raise RuntimeError( | |
| f"Layout mismatch: source={len(source_lines)}, target={len(target_lines)}" | |
| ) | |
| rows: list[tuple[int, str, str]] = [] | |
| row_index = 1 | |
| for source_line, target_line in zip(source_lines, target_lines): | |
| if source_line.strip(): | |
| rows.append((row_index, source_line.strip(), target_line.strip())) | |
| row_index += 1 | |
| return rows | |
| def is_heading_line(line: str) -> bool: | |
| """Dòng tiêu đề chương ngắn (giữ riêng 1 dòng, không gộp vào đoạn).""" | |
| stripped = (line or "").strip() | |
| if not stripped or "\n" in stripped: | |
| return False | |
| if len(stripped) > 24: | |
| return False | |
| if re.search(r"[。!?!?;;,,::\"“”]", stripped): | |
| return False | |
| return bool(HEADING_RE.match(stripped)) | |
| def split_vi_sentences(text: str) -> list[str]: | |
| """Tách bản dịch tiếng Việt thành danh sách câu (gọn khoảng trắng).""" | |
| units = [ | |
| re.sub(r"\s+", " ", m.group(0)).strip() | |
| for m in VI_SENTENCE_RE.finditer(text or "") | |
| ] | |
| return [u for u in units if u] | |
| def restore_line_breaks_by_sentences( | |
| source_text: str, target_text: str | |
| ) -> tuple[str | None, str]: | |
| """Phân bổ câu bản dịch về các dòng gốc theo tỉ lệ độ dài ký tự nguồn. | |
| Trả (None, lý do) khi số câu đích < số dòng gốc (không đủ để chia) → caller | |
| rơi sang canh-theo-ký-tự. | |
| """ | |
| source_lines = split_layout_lines(source_text) | |
| nonblank = [line.strip() for line in source_lines if line.strip()] | |
| if len(nonblank) <= 1: | |
| return _single_translation_layout(source_lines, target_text), "single-line" | |
| target_units = split_vi_sentences(target_text) | |
| if len(target_units) < len(nonblank): | |
| return None, "too-few-target-sentences" | |
| total_source_chars = max(sum(len(line) for line in nonblank), 1) | |
| total_target_units = len(target_units) | |
| remaining_lines = len(nonblank) | |
| source_seen = 0 | |
| target_cursor = 0 | |
| restored: list[str] = [] | |
| for line in source_lines: | |
| stripped = line.strip() | |
| if not stripped: | |
| restored.append("") | |
| continue | |
| source_seen += len(stripped) | |
| remaining_lines -= 1 | |
| ideal_end = round(source_seen / total_source_chars * total_target_units) | |
| min_end = target_cursor + 1 | |
| max_end = total_target_units - remaining_lines | |
| end = min(max(ideal_end, min_end), max_end) | |
| restored.append(" ".join(target_units[target_cursor:end]).strip()) | |
| target_cursor = end | |
| if target_cursor < total_target_units: | |
| for i in range(len(restored) - 1, -1, -1): | |
| if restored[i].strip(): | |
| restored[i] = ( | |
| restored[i] + " " + " ".join(target_units[target_cursor:]) | |
| ).strip() | |
| break | |
| return "\n".join(restored), "sentence-proportional" | |
| def restore_line_breaks_by_dp( | |
| source_text: str, target_text: str | |
| ) -> tuple[str | None, str]: | |
| """Chia câu đích về dòng nguồn bằng monotonic DP theo tỉ lệ độ dài. | |
| Mỗi dòng nguồn không-rỗng nhận ít nhất một câu đích. Nếu model gộp quá mạnh | |
| khiến số câu đích ít hơn số dòng nguồn, caller phải fallback line-by-line | |
| hoặc char-proportional tùy ngữ cảnh. | |
| """ | |
| source_lines = split_layout_lines(source_text) | |
| nonblank = [line.strip() for line in source_lines if line.strip()] | |
| if len(nonblank) <= 1: | |
| return _single_translation_layout(source_lines, target_text), "single-line" | |
| target_units = split_vi_sentences(target_text) | |
| if len(target_units) < len(nonblank): | |
| return None, "too-few-target-sentences" | |
| n = len(nonblank) | |
| m = len(target_units) | |
| # DP là O(n·m²) — chỉ rẻ ở quy mô chunk (CT2 plan-path: n ≤ ~12). Đường | |
| # legacy (plan=None, backend PyTorch) có thể đưa cả tài liệu vào đây: | |
| # 400 dòng/1200 câu đo được ~26s, 2000 dòng ~50 phút. Vượt ngân sách → | |
| # trả None để caller dùng sentence-proportional (tuyến tính). | |
| if n * m * m > 100_000_000: | |
| return None, "dp-too-large" | |
| source_chars = [max(len(line), 1) for line in nonblank] | |
| target_chars = [max(len(unit), 1) for unit in target_units] | |
| total_source = max(sum(source_chars), 1) | |
| total_target = max(sum(target_chars), 1) | |
| prefix = [0] | |
| for count in target_chars: | |
| prefix.append(prefix[-1] + count) | |
| inf = float("inf") | |
| dp = [[inf] * (m + 1) for _ in range(n + 1)] | |
| prev = [[-1] * (m + 1) for _ in range(n + 1)] | |
| dp[0][0] = 0.0 | |
| for i in range(1, n + 1): | |
| min_j = i | |
| max_j = m - (n - i) | |
| for j in range(min_j, max_j + 1): | |
| for k in range(i - 1, j): | |
| if dp[i - 1][k] == inf: | |
| continue | |
| group_chars = prefix[j] - prefix[k] | |
| src_ratio = source_chars[i - 1] / total_source | |
| tgt_ratio = group_chars / total_target | |
| group_size = j - k | |
| cost = (src_ratio - tgt_ratio) ** 2 | |
| if group_size > 1: | |
| cost += 0.002 * (group_size - 1) | |
| if dp[i - 1][k] + cost < dp[i][j]: | |
| dp[i][j] = dp[i - 1][k] + cost | |
| prev[i][j] = k | |
| if prev[n][m] < 0: | |
| return None, "dp-no-path" | |
| groups: list[tuple[int, int]] = [] | |
| i, j = n, m | |
| while i > 0: | |
| k = prev[i][j] | |
| groups.append((k, j)) | |
| i, j = i - 1, k | |
| groups.reverse() | |
| restored: list[str] = [] | |
| group_cursor = 0 | |
| for line in source_lines: | |
| if not line.strip(): | |
| restored.append("") | |
| continue | |
| start, end = groups[group_cursor] | |
| restored.append(" ".join(target_units[start:end]).strip()) | |
| group_cursor += 1 | |
| return "\n".join(restored), "sentence-dp" | |
| def find_nearest_break(text: str, desired: int, min_pos: int, max_pos: int) -> int: | |
| """Tìm vị trí ngắt gần `desired` nhất rơi vào ranh giới câu/khoảng trắng.""" | |
| desired = min(max(desired, min_pos), max_pos) | |
| window_start = max(min_pos, desired - 80) | |
| window_end = min(max_pos, desired + 80) | |
| best_idx, best_score = desired, float("inf") | |
| for idx in range(window_start, window_end + 1): | |
| prev = text[idx - 1] if idx > 0 else "" | |
| cur = text[idx] if idx < len(text) else "" | |
| if prev in VI_BREAK_CHARS or cur.isspace(): | |
| score = abs(idx - desired) | |
| if prev in ".!?…。!?": | |
| score -= 8 | |
| if score < best_score: | |
| best_idx, best_score = idx, score | |
| return best_idx | |
| def restore_line_breaks_by_chars( | |
| source_text: str, target_text: str | |
| ) -> tuple[str, str]: | |
| """Fallback: chia bản dịch theo tỉ lệ ký tự, ngắt tại ranh giới gần nhất.""" | |
| source_lines = split_layout_lines(source_text) | |
| nonblank = [line.strip() for line in source_lines if line.strip()] | |
| if len(nonblank) <= 1: | |
| return _single_translation_layout(source_lines, target_text), "single-line" | |
| compact_target = re.sub(r"\s+", " ", target_text or "").strip() | |
| if not compact_target: | |
| return _layout_from_nonblank_translations(source_text, [""] * len(nonblank)), "char-proportional" | |
| total_source_chars = max(sum(len(line) for line in nonblank), 1) | |
| target_len = len(compact_target) | |
| source_seen = 0 | |
| last_pos = 0 | |
| parts: list[str] = [] | |
| for line in nonblank[:-1]: | |
| source_seen += len(line) | |
| desired = round(source_seen / total_source_chars * target_len) | |
| remaining_parts = len(nonblank) - len(parts) - 1 | |
| min_pos = min(last_pos + 1, target_len) | |
| max_pos = min(target_len, max(min_pos, target_len - remaining_parts)) | |
| pos = find_nearest_break(compact_target, desired, min_pos, max_pos) | |
| parts.append(compact_target[last_pos:pos].strip()) | |
| last_pos = pos | |
| parts.append(compact_target[last_pos:].strip()) | |
| restored: list[str] = [] | |
| part_cursor = 0 | |
| for line in source_lines: | |
| if line.strip(): | |
| restored.append(parts[part_cursor] if part_cursor < len(parts) else "") | |
| part_cursor += 1 | |
| else: | |
| restored.append("") | |
| return "\n".join(restored), "char-proportional" | |
| FRAGMENT_END_RE = re.compile( | |
| r"\b(?:mùng|ngày|tháng|năm|vào|ở|tại|trong|ngoài|với|và|của|là|một|" | |
| r"các|những|đến|từ|sau|trước|bằng|cho|cùng|đang|đã|sẽ|vừa|rồi)$", | |
| re.I, | |
| ) | |
| def _starts_lower_or_digit(text: str) -> bool: | |
| stripped = (text or "").lstrip() | |
| if not stripped: | |
| return False | |
| first = stripped[0] | |
| return first.isdigit() or first.islower() | |
| def _looks_fragmented(lines: list[str]) -> bool: | |
| for current, nxt in zip(lines, lines[1:]): | |
| stripped = current.strip() | |
| if not stripped: | |
| return True | |
| comparable = stripped.rstrip(" .!?…;:,,") | |
| if FRAGMENT_END_RE.search(comparable) and _starts_lower_or_digit(nxt): | |
| return True | |
| if not re.search(r"[.!?…;:。!?;”’\"']$", stripped) and _starts_lower_or_digit(nxt): | |
| return True | |
| return False | |
| def _restore_chunk_lines( | |
| source_lines: list[str], | |
| target_text: str, | |
| ) -> tuple[list[str], str, bool]: | |
| source_text = "\n".join(source_lines) | |
| if len(source_lines) <= 1: | |
| return [(target_text or "").strip()], "single-line", True | |
| restored, mode = restore_line_breaks_by_dp(source_text, target_text) | |
| confident = restored is not None | |
| if restored is None: | |
| restored, mode = restore_line_breaks_by_chars(source_text, target_text) | |
| restored_lines = [ | |
| line.strip() | |
| for line in split_layout_lines(restored) | |
| if line.strip() or len(source_lines) == 1 | |
| ] | |
| if len(restored_lines) != len(source_lines): | |
| restored_lines = (restored_lines + [""] * len(source_lines))[: len(source_lines)] | |
| confident = False | |
| if _looks_fragmented(restored_lines): | |
| confident = False | |
| return restored_lines, mode, confident | |
| def paragraph_chunk_fallback_indices( | |
| source_text: str, | |
| chunks: list[str], | |
| translations: list[str], | |
| plan: list[tuple[int, ...]], | |
| ) -> list[int]: | |
| """Các chunk nhiều dòng nên dịch lại từng dòng vì restore thiếu tin cậy.""" | |
| if len(chunks) != len(translations) or len(chunks) != len(plan): | |
| raise RuntimeError( | |
| f"Chunk/translation/plan lệch số lượng: {len(chunks)} != " | |
| f"{len(translations)} != {len(plan)}" | |
| ) | |
| source_lines = split_layout_lines(source_text) | |
| fallback: list[int] = [] | |
| for chunk_index, (line_indices, translated_chunk) in enumerate(zip(plan, translations)): | |
| if len(line_indices) <= 1: | |
| continue | |
| chunk_source_lines = [source_lines[i].strip() for i in line_indices] | |
| _lines, _mode, confident = _restore_chunk_lines( | |
| chunk_source_lines, | |
| translated_chunk or "", | |
| ) | |
| if not confident: | |
| fallback.append(chunk_index) | |
| return fallback | |
| def _restore_chunks_by_layout( | |
| source_text: str, | |
| chunks: list[str], | |
| translations: list[str], | |
| ) -> str | None: | |
| """Restore từng chunk rồi đặt lại theo layout toàn nguồn nếu map dòng còn rõ. | |
| Không có ChunkPlan nên ta chỉ dùng đường này khi tổng số dòng non-blank trong | |
| chunks khớp với nguồn. Nếu một dòng dài bị char-split thành nhiều chunk, tổng | |
| này sẽ vượt nguồn và caller fallback về thuật toán toàn tài liệu cũ. | |
| """ | |
| source_nonblank_count = _nonblank_line_count(source_text) | |
| chunk_line_counts = [_nonblank_line_count(chunk) for chunk in chunks] | |
| if sum(chunk_line_counts) != source_nonblank_count: | |
| return None | |
| translated_lines: list[str] = [] | |
| for source_chunk, translated_chunk in zip(chunks, translations): | |
| source_lines = split_layout_lines(source_chunk) | |
| restored_chunk, _mode = restore_line_breaks(source_chunk, translated_chunk or "") | |
| target_lines = split_layout_lines(restored_chunk) | |
| if len(source_lines) != len(target_lines): | |
| return None | |
| for source_line, target_line in zip(source_lines, target_lines): | |
| if source_line.strip(): | |
| translated_lines.append(target_line.strip()) | |
| if len(translated_lines) != source_nonblank_count: | |
| return None | |
| return _layout_from_nonblank_translations(source_text, translated_lines) | |
| def assemble_paragraph_output( | |
| source_text: str, | |
| chunks: list[str], | |
| translations: list[str], | |
| plan: list[tuple[int, ...]] | None = None, | |
| fallback_line_translations: dict[int, str] | None = None, | |
| ) -> tuple[list[tuple[int, str, str]], str]: | |
| """Ghép kết quả chế độ "Theo đoạn" rồi khôi phục bố cục dòng. | |
| Trả `(rows, full_text)` trong đó `full_text` có ĐÚNG số dòng (kể cả dòng | |
| trống) như văn bản nguồn, và `rows` ghép 1:1 từng dòng-gốc-không-rỗng với | |
| dòng-dịch tương ứng (để bảng đối chiếu khớp với bản tải về). | |
| Quy trình khớp Space `translate_per_chunk`: nối các chunk dịch (tiêu đề chèn | |
| \\n để tách riêng), gọn khoảng trắng, rồi `restore_line_breaks`. | |
| """ | |
| if len(chunks) != len(translations): | |
| raise RuntimeError( | |
| f"Chunk/translation lệch số lượng: {len(chunks)} != {len(translations)}" | |
| ) | |
| if plan is not None: | |
| if len(plan) != len(chunks): | |
| raise RuntimeError( | |
| f"Chunk/plan lệch số lượng: {len(chunks)} != {len(plan)}" | |
| ) | |
| source_lines = split_layout_lines(source_text) | |
| line_parts: list[list[str]] = [[] for _ in source_lines] | |
| fallback_line_translations = fallback_line_translations or {} | |
| for chunk_index, (line_indices, translated_chunk) in enumerate(zip(plan, translations)): | |
| del chunk_index | |
| translated_chunk = (translated_chunk or "").strip() | |
| if not line_indices: | |
| continue | |
| # Invariant với translator: fallback được chọn theo NGUYÊN CHUNK | |
| # (paragraph_chunk_fallback_indices trả chunk-index, mọi line của | |
| # chunk đó đều được dịch lại) nên all() ở đây nhận trọn chunk hoặc | |
| # không gì cả. Nếu đổi sang fallback theo dòng lẻ, phải chuyển sang | |
| # xử lý per-line, không thì các dòng partial bị bỏ qua âm thầm. | |
| if all(line_index in fallback_line_translations for line_index in line_indices): | |
| for line_index in line_indices: | |
| line_parts[line_index].append(fallback_line_translations[line_index].strip()) | |
| continue | |
| if len(line_indices) == 1: | |
| line_parts[line_indices[0]].append(translated_chunk) | |
| continue | |
| chunk_source_lines = [source_lines[i].strip() for i in line_indices] | |
| restored_lines, _mode, _confident = _restore_chunk_lines( | |
| chunk_source_lines, | |
| translated_chunk, | |
| ) | |
| for line_index, restored_line in zip(line_indices, restored_lines): | |
| line_parts[line_index].append(restored_line.strip()) | |
| out_lines: list[str] = [] | |
| for source_line, parts in zip(source_lines, line_parts): | |
| if source_line.strip(): | |
| out_lines.append(" ".join(part for part in parts if part).strip()) | |
| else: | |
| out_lines.append("") | |
| full_text = "\n".join(out_lines) | |
| return _rows_from_layout(source_text, full_text), full_text | |
| full_text = _restore_chunks_by_layout(source_text, chunks, translations) | |
| if full_text is None: | |
| parts: list[str] = [] | |
| for source_chunk, translated_chunk in zip(chunks, translations): | |
| translated_chunk = (translated_chunk or "").strip() | |
| if is_heading_line(source_chunk): | |
| parts.append(f"\n{translated_chunk}\n") | |
| else: | |
| parts.append(translated_chunk) | |
| raw_output = " ".join(parts) | |
| raw_output = re.sub(r"[ \t]+", " ", raw_output) | |
| raw_output = re.sub(r" *\n *", "\n", raw_output) | |
| full_text, _mode = restore_line_breaks(source_text, raw_output) | |
| return _rows_from_layout(source_text, full_text), full_text | |
| def assemble_sentence_output( | |
| source_text: str, | |
| chunks: list[str], | |
| translations: list[str], | |
| plan: list[list[int] | None], | |
| ) -> tuple[list[tuple[int, str, str]], str]: | |
| """Ghép kết quả chế độ "Theo câu" GIỮ bố cục dòng (kể cả dòng trống). | |
| `plan` (từ split_sentence_lines_with_plan) ánh xạ từng dòng nguồn → chunk-index | |
| (None nếu dòng trống). Vì "Theo câu" KHÔNG gom dòng (mỗi chunk nằm gọn trong 1 | |
| dòng nguồn), việc ghép là DETERMINISTIC: nối các chunk dịch cùng dòng bằng | |
| " " → 1 dòng output; dòng trống → "" → GIỮ bố cục. Không phỏng đoán tỉ lệ như | |
| "Theo đoạn". Trả `(rows, full_text)`: full_text đúng layout nguồn, rows 1 dòng | |
| non-blank/1 row (ghép multi-chunk), để bảng đối chiếu khớp bản tải về. | |
| """ | |
| if len(chunks) != len(translations): | |
| raise RuntimeError( | |
| f"Chunk/translation lệch số lượng: {len(chunks)} != {len(translations)}" | |
| ) | |
| source_lines = split_layout_lines(source_text) | |
| out_lines: list[str] = [] | |
| for source_line, indices in zip(source_lines, plan): | |
| if indices is None: | |
| out_lines.append("") | |
| else: | |
| out_lines.append( | |
| " ".join((translations[i] or "").strip() for i in indices).strip() | |
| ) | |
| full_text = "\n".join(out_lines) | |
| return _rows_from_layout(source_text, full_text), full_text | |
| def restore_line_breaks(source_text: str, target_text: str) -> tuple[str, str]: | |
| """Khôi phục bố cục dòng của bản dịch khớp với văn bản nguồn. | |
| Orchestrator: nếu dòng đầu là tiêu đề chương thì tách riêng nó rồi xử lý phần | |
| thân; còn lại thử canh-theo-câu trước, không được thì canh-theo-ký-tự. Luôn | |
| trả về chuỗi có đúng bố cục dòng (kể cả dòng trống) của nguồn. | |
| """ | |
| source_lines = split_layout_lines(source_text) | |
| first_nonblank = next( | |
| (i for i, line in enumerate(source_lines) if line.strip()), None | |
| ) | |
| target_lines = [line.strip() for line in split_layout_lines(target_text)] | |
| target_lines = [line for line in target_lines if line] | |
| if ( | |
| first_nonblank is not None | |
| and len(target_lines) > 1 | |
| and is_heading_line(source_lines[first_nonblank]) | |
| ): | |
| body_start = first_nonblank + 1 | |
| blank_after_heading = 0 | |
| while body_start < len(source_lines) and not source_lines[body_start].strip(): | |
| blank_after_heading += 1 | |
| body_start += 1 | |
| body_source = "\n".join(source_lines[body_start:]) | |
| body_target = "\n".join(target_lines[1:]) | |
| restored_body, body_mode = restore_line_breaks(body_source, body_target) | |
| restored = [""] * first_nonblank | |
| restored.append(target_lines[0]) | |
| restored.extend([""] * blank_after_heading) | |
| if restored_body: | |
| restored.append(restored_body) | |
| return "\n".join(restored), f"heading+{body_mode}" | |
| restored, mode = restore_line_breaks_by_dp(source_text, target_text) | |
| if restored is not None: | |
| return restored, mode | |
| restored, mode = restore_line_breaks_by_sentences(source_text, target_text) | |
| if restored is not None: | |
| return restored, mode | |
| return restore_line_breaks_by_chars(source_text, target_text) | |