Spaces:
Running
Running
| """Token-aware chunking for Marian models (ported from HachimiMT HF Space).""" | |
| from __future__ import annotations | |
| import re | |
| from typing import TYPE_CHECKING | |
| if TYPE_CHECKING: | |
| from transformers import PreTrainedTokenizerBase | |
| # Câu nguồn: nuốt tới cụm dấu kết (gồm 。!?!?;; và … ……) kèm ngoặc đóng theo | |
| # sau (」』)》】 và ”’ " '), hoặc hết chuỗi. Giữ ngoặc đóng Ở LẠI câu vừa kết, | |
| # không đẩy sang câu sau (trước đây 」 sau 。 rơi nhầm vào câu kế tiếp). | |
| SENTENCE_RE = re.compile( | |
| r".+?(?:[。!?!?;;…]+[」』)》】”’\"']*|$)", | |
| re.S, | |
| ) | |
| HEADING_RE = re.compile(r"^第[0-9零〇一二三四五六七八九十百千万两]+[章节回卷部篇]") | |
| METADATA_RE = re.compile(r"^(?:书名|作者|简介|内容简介|作品简介)\s*[::]") | |
| def is_hard_boundary_line(line: str) -> bool: | |
| """Lines that must stay standalone even in paragraph chunk mode.""" | |
| stripped = (line or "").strip() | |
| if not stripped or "\n" in stripped: | |
| return False | |
| if METADATA_RE.match(stripped): | |
| return True | |
| if len(stripped) > 32: | |
| return False | |
| if re.search(r"[。!?!?;;,,::\"“”]", stripped): | |
| return False | |
| return bool(HEADING_RE.match(stripped)) | |
| def source_token_ids( | |
| tokenizer: PreTrainedTokenizerBase, | |
| text: str, | |
| *, | |
| max_length: int, | |
| truncation: bool, | |
| ) -> list[int]: | |
| token_ids = tokenizer( | |
| text, | |
| truncation=truncation, | |
| max_length=max_length, | |
| )["input_ids"] | |
| if tokenizer.pad_token_id is not None: | |
| token_ids = [tid for tid in token_ids if tid != tokenizer.pad_token_id] | |
| return token_ids | |
| def source_token_count( | |
| tokenizer: PreTrainedTokenizerBase, | |
| text: str, | |
| *, | |
| max_length: int, | |
| ) -> int: | |
| return len(source_token_ids(tokenizer, text, max_length=max_length, truncation=False)) | |
| def char_chunks( | |
| tokenizer: PreTrainedTokenizerBase, | |
| text: str, | |
| *, | |
| max_tokens: int, | |
| ) -> list[str]: | |
| chunks: list[str] = [] | |
| remaining = text | |
| while remaining: | |
| if source_token_count(tokenizer, remaining, max_length=max_tokens) <= max_tokens: | |
| chunks.append(remaining) | |
| break | |
| low, high = 1, len(remaining) | |
| best = 1 | |
| while low <= high: | |
| middle = (low + high) // 2 | |
| candidate = remaining[:middle] | |
| if source_token_count(tokenizer, candidate, max_length=max_tokens) <= max_tokens: | |
| best = middle | |
| low = middle + 1 | |
| else: | |
| high = middle - 1 | |
| chunks.append(remaining[:best]) | |
| remaining = remaining[best:] | |
| return chunks | |
| def sentence_chunks( | |
| tokenizer: PreTrainedTokenizerBase, | |
| line: str, | |
| *, | |
| max_tokens: int, | |
| ) -> list[str]: | |
| if source_token_count(tokenizer, line, max_length=max_tokens) <= max_tokens: | |
| return [line] | |
| pieces = [match.group(0) for match in SENTENCE_RE.finditer(line)] | |
| if not pieces: | |
| return char_chunks(tokenizer, line, max_tokens=max_tokens) | |
| chunks: list[str] = [] | |
| current = "" | |
| for piece in pieces: | |
| if source_token_count(tokenizer, piece, max_length=max_tokens) > max_tokens: | |
| if current: | |
| chunks.append(current) | |
| current = "" | |
| chunks.extend(char_chunks(tokenizer, piece, max_tokens=max_tokens)) | |
| continue | |
| candidate = current + piece | |
| if current and source_token_count(tokenizer, candidate, max_length=max_tokens) > max_tokens: | |
| chunks.append(current) | |
| current = piece | |
| else: | |
| current = candidate | |
| if current: | |
| chunks.append(current) | |
| return chunks | |
| def _layout_lines(text: str) -> list[str]: | |
| """Tách dòng GIỮ dòng rỗng đầu/cuối, chuẩn hóa newline (cho plan-based).""" | |
| return (text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n") | |
| def split_sentence_lines_with_plan( | |
| tokenizer: PreTrainedTokenizerBase, | |
| text: str, | |
| *, | |
| max_tokens: int, | |
| ) -> tuple[list[str], list[list[int] | None]]: | |
| """Chia "Theo câu" KÈM plan ánh xạ dòng-nguồn → chunk-index. | |
| Trả `(chunks, plan)` với `plan` theo TỪNG dòng nguồn (giữ cả dòng trống): | |
| `None` nếu dòng trống, hoặc danh sách index các chunk thuộc dòng đó (một dòng | |
| dài vượt cap có thể tách thành nhiều chunk → nhiều index). Cho phép ghép lại | |
| output theo đúng bố cục dòng nguồn (giữ dòng trống), điều mà `split_for_ | |
| translation` thường (trả phẳng list[str]) không làm được. | |
| """ | |
| chunks: list[str] = [] | |
| plan: list[list[int] | None] = [] | |
| for line in _layout_lines(text): | |
| stripped = line.strip() | |
| if not stripped: | |
| plan.append(None) | |
| continue | |
| line_chunks = sentence_chunks(tokenizer, stripped, max_tokens=max_tokens) | |
| indices = list(range(len(chunks), len(chunks) + len(line_chunks))) | |
| chunks.extend(line_chunks) | |
| plan.append(indices) | |
| return chunks, plan | |
| def split_paragraphs_with_plan( | |
| tokenizer: PreTrainedTokenizerBase, | |
| text: str, | |
| *, | |
| max_tokens: int, | |
| ) -> tuple[list[str], list[tuple[int, ...]]]: | |
| """Chia "Theo đoạn" thật, kèm map chunk -> line-index tuyệt đối. | |
| Khác "Theo câu": các dòng liên tiếp trong cùng đoạn được pack vào một chunk | |
| tới sát cap để giảm số chunk và giữ context. Khác bản cũ: mỗi chunk giữ danh | |
| sách line-index nguồn, cho phép restore/fallback theo từng chunk thay vì đoán | |
| toàn tài liệu. | |
| """ | |
| chunks: list[str] = [] | |
| plan: list[tuple[int, ...]] = [] | |
| buffered_lines: list[str] = [] | |
| buffered_indices: list[int] = [] | |
| def flush_buffer() -> None: | |
| if not buffered_lines: | |
| return | |
| chunks.append("\n".join(buffered_lines)) | |
| plan.append(tuple(buffered_indices)) | |
| buffered_lines.clear() | |
| buffered_indices.clear() | |
| def add_line_chunk(line_index: int, line: str) -> None: | |
| for piece in sentence_chunks(tokenizer, line, max_tokens=max_tokens): | |
| chunks.append(piece) | |
| plan.append((line_index,)) | |
| for line_index, line in enumerate(_layout_lines(text)): | |
| stripped = line.strip() | |
| if not stripped: | |
| flush_buffer() | |
| continue | |
| if is_hard_boundary_line(stripped): | |
| flush_buffer() | |
| add_line_chunk(line_index, stripped) | |
| continue | |
| if source_token_count(tokenizer, stripped, max_length=max_tokens) > max_tokens: | |
| flush_buffer() | |
| add_line_chunk(line_index, stripped) | |
| continue | |
| candidate_lines = [*buffered_lines, stripped] | |
| candidate = "\n".join(candidate_lines) | |
| if buffered_lines and source_token_count(tokenizer, candidate, max_length=max_tokens) > max_tokens: | |
| flush_buffer() | |
| buffered_lines.append(stripped) | |
| buffered_indices.append(line_index) | |
| flush_buffer() | |
| return chunks, plan | |
| def split_for_translation( | |
| tokenizer: PreTrainedTokenizerBase, | |
| text: str, | |
| *, | |
| max_tokens: int, | |
| chunk_mode: str = "sentence", | |
| ) -> list[str]: | |
| """Split *text* into chunks that fit within *max_tokens*.""" | |
| text = text.strip() | |
| if not text: | |
| return [] | |
| if chunk_mode == "paragraph": | |
| chunks, _plan = split_paragraphs_with_plan( | |
| tokenizer, | |
| text, | |
| max_tokens=max_tokens, | |
| ) | |
| return chunks | |
| chunks = [] | |
| for line in text.splitlines(): | |
| line = line.strip() | |
| if not line: | |
| continue | |
| chunks.extend(sentence_chunks(tokenizer, line, max_tokens=max_tokens)) | |
| return chunks | |