2026-02-20 19:35:43 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import random
|
|
|
|
|
|
import re
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
|
|
|
|
|
|
BULLET_PATTERN = [
|
|
|
|
|
|
[
|
|
|
|
|
|
r"第[零一二三四五六七八九十百0-9]+(分?编|部分)",
|
|
|
|
|
|
r"第[零一二三四五六七八九十百0-9]+章",
|
|
|
|
|
|
r"第[零一二三四五六七八九十百0-9]+节",
|
|
|
|
|
|
r"第[零一二三四五六七八九十百0-9]+条",
|
|
|
|
|
|
r"[\((][零一二三四五六七八九十百]+[\))]",
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
r"第[0-9]+章",
|
|
|
|
|
|
r"第[0-9]+节",
|
|
|
|
|
|
r"[0-9]{,2}[\. 、]",
|
|
|
|
|
|
r"[0-9]{,2}\.[0-9]{,2}[^a-zA-Z/%~-]",
|
|
|
|
|
|
r"[0-9]{,2}\.[0-9]{,2}\.[0-9]{,2}",
|
|
|
|
|
|
r"[0-9]{,2}\.[0-9]{,2}\.[0-9]{,2}\.[0-9]{,2}",
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
r"第[零一二三四五六七八九十百0-9]+章",
|
|
|
|
|
|
r"第[零一二三四五六七八九十百0-9]+节",
|
|
|
|
|
|
r"[零一二三四五六七八九十百]+[ 、]",
|
|
|
|
|
|
r"[\((][零一二三四五六七八九十百]+[\))]",
|
|
|
|
|
|
r"[\((][0-9]{,2}[\))]",
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
r"PART (ONE|TWO|THREE|FOUR|FIVE|SIX|SEVEN|EIGHT|NINE|TEN)",
|
|
|
|
|
|
r"Chapter (I+V?|VI*|XI|IX|X)",
|
|
|
|
|
|
r"Section [0-9]+",
|
|
|
|
|
|
r"Article [0-9]+",
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
r"^#[^#]",
|
|
|
|
|
|
r"^##[^#]",
|
|
|
|
|
|
r"^###.*",
|
|
|
|
|
|
r"^####.*",
|
|
|
|
|
|
r"^#####.*",
|
|
|
|
|
|
r"^######.*",
|
|
|
|
|
|
],
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
MARKDOWN_BULLET_GROUP_INDEX = 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def count_tokens(text: str) -> int:
|
|
|
|
|
|
"""近似 token 计数,避免引入额外依赖。"""
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
# 英文单词 + 数字 + CJK 单字
|
|
|
|
|
|
parts = re.findall(r"[A-Za-z0-9_]+|[\u4e00-\u9fff]", text)
|
|
|
|
|
|
return max(1, len(parts)) if text.strip() else 0
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-06 17:45:32 +08:00
|
|
|
|
def hard_split_by_token_limit(text: str, chunk_token_num: int, hard_limit_token_num: int | None = None) -> list[str]:
|
|
|
|
|
|
"""将文本按 token 上限硬切,用于 naive_merge 之后的兜底保护。
|
|
|
|
|
|
|
|
|
|
|
|
hard_limit_token_num 只在调用方显式传入时生效,用于允许略超目标长度的块
|
|
|
|
|
|
保持完整;默认保持严格不超过 chunk_token_num 的历史行为。
|
|
|
|
|
|
"""
|
2026-05-01 22:19:08 +08:00
|
|
|
|
token_iter = list(re.finditer(r"[A-Za-z0-9_]+|[一-鿿]", text or ""))
|
|
|
|
|
|
if not token_iter:
|
|
|
|
|
|
cleaned = (text or "").strip()
|
|
|
|
|
|
return [cleaned] if cleaned else []
|
|
|
|
|
|
|
2026-06-06 17:45:32 +08:00
|
|
|
|
max_tokens = max(int(chunk_token_num or 0), 1)
|
|
|
|
|
|
hard_limit = None
|
|
|
|
|
|
if hard_limit_token_num is not None:
|
|
|
|
|
|
hard_limit = max(int(hard_limit_token_num or 0), max_tokens)
|
|
|
|
|
|
if len(token_iter) <= hard_limit:
|
|
|
|
|
|
cleaned = (text or "").strip()
|
|
|
|
|
|
return [cleaned] if cleaned else []
|
|
|
|
|
|
|
|
|
|
|
|
spans: list[tuple[int, int]] = []
|
2026-05-01 22:19:08 +08:00
|
|
|
|
start = 0
|
|
|
|
|
|
index = 0
|
|
|
|
|
|
|
|
|
|
|
|
while index < len(token_iter):
|
2026-05-17 01:18:03 +08:00
|
|
|
|
next_index = min(index + max_tokens, len(token_iter))
|
|
|
|
|
|
if next_index < len(token_iter):
|
|
|
|
|
|
end = token_iter[next_index].start()
|
|
|
|
|
|
else:
|
|
|
|
|
|
end = len(text)
|
2026-06-06 17:45:32 +08:00
|
|
|
|
if text[start:end].strip():
|
|
|
|
|
|
spans.append((start, end))
|
2026-05-01 22:19:08 +08:00
|
|
|
|
start = end
|
2026-05-17 01:18:03 +08:00
|
|
|
|
index = next_index
|
2026-05-01 22:19:08 +08:00
|
|
|
|
|
|
|
|
|
|
tail = text[start:].strip()
|
|
|
|
|
|
if tail:
|
2026-06-06 17:45:32 +08:00
|
|
|
|
spans.append((start, len(text)))
|
|
|
|
|
|
|
|
|
|
|
|
if hard_limit is not None and len(spans) >= 2:
|
|
|
|
|
|
prev_start, _ = spans[-2]
|
|
|
|
|
|
_, tail_end = spans[-1]
|
|
|
|
|
|
candidate = text[prev_start:tail_end].strip()
|
|
|
|
|
|
if count_tokens(candidate) <= hard_limit:
|
|
|
|
|
|
spans[-2] = (prev_start, tail_end)
|
|
|
|
|
|
spans.pop()
|
|
|
|
|
|
|
|
|
|
|
|
return [text[start:end].strip() for start, end in spans if text[start:end].strip()]
|
2026-05-01 22:19:08 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-02-20 19:35:43 +08:00
|
|
|
|
def random_choices(arr: list[str], k: int) -> list[str]:
|
|
|
|
|
|
if not arr:
|
|
|
|
|
|
return []
|
|
|
|
|
|
return random.choices(arr, k=min(len(arr), k))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_english(texts: str | list[str]) -> bool:
|
|
|
|
|
|
if not texts:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
patt = re.compile(r"[`a-zA-Z0-9\s.,':;/\"?<>!\(\)\-]+")
|
|
|
|
|
|
if isinstance(texts, str):
|
|
|
|
|
|
seq = [texts]
|
|
|
|
|
|
else:
|
|
|
|
|
|
seq = [t for t in texts if isinstance(t, str) and t.strip()]
|
|
|
|
|
|
|
|
|
|
|
|
if not seq:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
hits = sum(1 for t in seq if patt.fullmatch(t.strip()))
|
|
|
|
|
|
return (hits / len(seq)) > 0.8
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def not_bullet(line: str) -> bool:
|
|
|
|
|
|
patt = [
|
|
|
|
|
|
r"0",
|
|
|
|
|
|
r"[0-9]+ +[0-9~个只-]",
|
|
|
|
|
|
r"[0-9]+\.{2,}",
|
|
|
|
|
|
]
|
|
|
|
|
|
return any(re.match(p, line) for p in patt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_probable_heading_line(line: str) -> bool:
|
|
|
|
|
|
text = (line or "").strip()
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
if re.match(r"^#{1,6}\s+\S", text):
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
# 表格/HTML 残留通常不是标题。
|
|
|
|
|
|
if re.search(r"</?(table|tr|td|th|caption|tbody|thead)[^>]*>", text, flags=re.IGNORECASE):
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# 超长行基本是正文或条款,不是章节标题。
|
|
|
|
|
|
if len(text) > 96:
|
|
|
|
|
|
return False
|
|
|
|
|
|
if count_tokens(text) > 72:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# 标题前段通常不会出现明显句号/逗号;出现则大概率是正文。
|
|
|
|
|
|
if re.search(r"[,。;!?!?::]", text[:24]):
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
if text.endswith(("。", ";", "!", "!", "?", "?")) and len(text) > 20:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_mid_sentence_bullet(line: str) -> bool:
|
|
|
|
|
|
text = (line or "").strip()
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
if re.match(r"^#{1,6}\s+\S", text):
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
marker = re.search(
|
|
|
|
|
|
r"([一二三四五六七八九十百]+、|[\((][一二三四五六七八九十百]+[\))]|[0-9]{1,2}[\.、])",
|
|
|
|
|
|
text,
|
|
|
|
|
|
)
|
|
|
|
|
|
if not marker:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
if marker.start() == 0:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
prev = text[marker.start() - 1]
|
|
|
|
|
|
return prev not in {"#", "\n"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def bullets_category(sections: list[str]) -> int:
|
|
|
|
|
|
hits: list[float] = [0.0] * len(BULLET_PATTERN)
|
|
|
|
|
|
|
|
|
|
|
|
def bullet_weight(group_idx: int, line: str) -> float:
|
|
|
|
|
|
# 对 markdown 标题候选增加权重,避免“正文里的 一、/(一)”压过真正的 # 标题层级。
|
|
|
|
|
|
if group_idx != MARKDOWN_BULLET_GROUP_INDEX:
|
|
|
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
|
|
heading = line.strip()
|
|
|
|
|
|
if not re.match(r"^#{1,6}\s+\S", heading):
|
|
|
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
|
|
level = len(heading) - len(heading.lstrip("#"))
|
|
|
|
|
|
if level <= 2:
|
|
|
|
|
|
return 4.0
|
|
|
|
|
|
if level <= 4:
|
|
|
|
|
|
return 3.0
|
|
|
|
|
|
return 2.0
|
|
|
|
|
|
|
|
|
|
|
|
for i, pro in enumerate(BULLET_PATTERN):
|
|
|
|
|
|
for sec in sections:
|
|
|
|
|
|
sec = sec.strip()
|
|
|
|
|
|
for p in pro:
|
|
|
|
|
|
if re.match(p, sec) and not not_bullet(sec):
|
|
|
|
|
|
w = bullet_weight(i, sec)
|
|
|
|
|
|
if _is_mid_sentence_bullet(sec):
|
|
|
|
|
|
w *= 0.1
|
|
|
|
|
|
if i != MARKDOWN_BULLET_GROUP_INDEX and not is_probable_heading_line(sec):
|
|
|
|
|
|
w *= 0.2
|
|
|
|
|
|
hits[i] += w
|
|
|
|
|
|
break
|
|
|
|
|
|
maximum = 0
|
|
|
|
|
|
res = -1
|
|
|
|
|
|
for i, hit in enumerate(hits):
|
|
|
|
|
|
if hit <= maximum:
|
|
|
|
|
|
continue
|
|
|
|
|
|
res = i
|
|
|
|
|
|
maximum = hit
|
|
|
|
|
|
return res
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_text(section: str | tuple[str, str]) -> str:
|
|
|
|
|
|
if isinstance(section, str):
|
|
|
|
|
|
return section.strip()
|
|
|
|
|
|
return (section[0] or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def remove_contents_table(sections: list[str] | list[tuple[str, str]], eng: bool = False) -> None:
|
|
|
|
|
|
i = 0
|
|
|
|
|
|
while i < len(sections):
|
|
|
|
|
|
line = re.sub(r"( | |\u3000)+", "", _get_text(sections[i]).split("@@")[0], flags=re.IGNORECASE)
|
|
|
|
|
|
if not re.match(r"(contents|目录|目次|tableofcontents|致谢|acknowledge)$", line, flags=re.IGNORECASE):
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
sections.pop(i)
|
|
|
|
|
|
if i >= len(sections):
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
prefix = _get_text(sections[i])[:3] if not eng else " ".join(_get_text(sections[i]).split()[:2])
|
|
|
|
|
|
while not prefix and i < len(sections):
|
|
|
|
|
|
sections.pop(i)
|
|
|
|
|
|
if i >= len(sections):
|
|
|
|
|
|
break
|
|
|
|
|
|
prefix = _get_text(sections[i])[:3] if not eng else " ".join(_get_text(sections[i]).split()[:2])
|
|
|
|
|
|
|
|
|
|
|
|
if i >= len(sections) or not prefix:
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
sections.pop(i)
|
|
|
|
|
|
if i >= len(sections):
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
for j in range(i, min(i + 128, len(sections))):
|
|
|
|
|
|
if not re.match(re.escape(prefix), _get_text(sections[j])):
|
|
|
|
|
|
continue
|
|
|
|
|
|
for _ in range(i, j):
|
|
|
|
|
|
sections.pop(i)
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def make_colon_as_title(sections: list[str] | list[tuple[str, str]]) -> list[str] | list[tuple[str, str]]:
|
|
|
|
|
|
if not sections:
|
|
|
|
|
|
return sections
|
|
|
|
|
|
if isinstance(sections[0], str):
|
|
|
|
|
|
return sections
|
|
|
|
|
|
|
|
|
|
|
|
i = 0
|
|
|
|
|
|
while i < len(sections):
|
|
|
|
|
|
text, layout = sections[i]
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
text = text.split("@")[0].strip()
|
|
|
|
|
|
if not text or text[-1] not in "::":
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
rev = text[::-1]
|
|
|
|
|
|
arr = re.split(r"([。?!!?;;]| \.)", rev)
|
|
|
|
|
|
if len(arr) < 2 or len(arr[1]) < 32:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
sections.insert(i - 1, (arr[0][::-1], "title"))
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
|
|
|
|
|
|
return sections
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def not_title(text: str) -> bool:
|
|
|
|
|
|
if re.match(r"第[零一二三四五六七八九十百0-9]+条", text):
|
|
|
|
|
|
return False
|
|
|
|
|
|
if len(text.split()) > 12 or (" " not in text and len(text) >= 32):
|
|
|
|
|
|
return True
|
|
|
|
|
|
return bool(re.search(r"[,;,。;!!]", text))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def tree_merge(bull: int, sections: list[str] | list[tuple[str, str]], depth: int) -> list[str]:
|
|
|
|
|
|
if not sections or bull < 0:
|
|
|
|
|
|
return [s if isinstance(s, str) else s[0] for s in sections]
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(sections[0], str):
|
|
|
|
|
|
typed_sections: list[tuple[str, str]] = [(s, "") for s in sections]
|
|
|
|
|
|
else:
|
|
|
|
|
|
typed_sections = sections # type: ignore[assignment]
|
|
|
|
|
|
|
|
|
|
|
|
typed_sections = [
|
|
|
|
|
|
(t, o)
|
|
|
|
|
|
for t, o in typed_sections
|
|
|
|
|
|
if t and len(t.split("@")[0].strip()) > 1 and not re.match(r"[0-9]+$", t.split("@")[0].strip())
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
def get_level(section: tuple[str, str]) -> tuple[int, str]:
|
|
|
|
|
|
text, layout = section
|
|
|
|
|
|
text = re.sub(r"\u3000", " ", text).strip()
|
|
|
|
|
|
|
|
|
|
|
|
for i, patt in enumerate(BULLET_PATTERN[bull]):
|
|
|
|
|
|
if re.match(patt, text) and is_probable_heading_line(text):
|
|
|
|
|
|
return i + 1, text
|
|
|
|
|
|
|
|
|
|
|
|
if re.search(r"(title|head)", layout) and not not_title(text):
|
|
|
|
|
|
return len(BULLET_PATTERN[bull]) + 1, text
|
|
|
|
|
|
|
|
|
|
|
|
return len(BULLET_PATTERN[bull]) + 2, text
|
|
|
|
|
|
|
|
|
|
|
|
lines: list[tuple[int, str]] = []
|
|
|
|
|
|
level_set: set[int] = set()
|
|
|
|
|
|
for section in typed_sections:
|
|
|
|
|
|
level, text = get_level(section)
|
|
|
|
|
|
if not text.strip("\n"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
lines.append((level, text))
|
|
|
|
|
|
level_set.add(level)
|
|
|
|
|
|
|
|
|
|
|
|
if not lines:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
sorted_levels = sorted(level_set)
|
|
|
|
|
|
target_level = sorted_levels[depth - 1] if depth <= len(sorted_levels) else sorted_levels[-1]
|
|
|
|
|
|
|
|
|
|
|
|
max_body_level = len(BULLET_PATTERN[bull]) + 2
|
|
|
|
|
|
if target_level == max_body_level:
|
|
|
|
|
|
target_level = sorted_levels[-2] if len(sorted_levels) > 1 else sorted_levels[0]
|
|
|
|
|
|
|
|
|
|
|
|
root = Node(level=0, depth=target_level, texts=[])
|
|
|
|
|
|
root.build_tree(lines)
|
|
|
|
|
|
return [item for item in root.get_tree() if item]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def hierarchical_merge(bull: int, sections: list[str] | list[tuple[str, str]], depth: int) -> list[list[str]]:
|
|
|
|
|
|
if not sections or bull < 0:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(sections[0], str):
|
|
|
|
|
|
typed_sections: list[tuple[str, str]] = [(s, "") for s in sections]
|
|
|
|
|
|
else:
|
|
|
|
|
|
typed_sections = sections # type: ignore[assignment]
|
|
|
|
|
|
|
|
|
|
|
|
typed_sections = [
|
|
|
|
|
|
(t, o)
|
|
|
|
|
|
for t, o in typed_sections
|
|
|
|
|
|
if t and len(t.split("@")[0].strip()) > 1 and not re.match(r"[0-9]+$", t.split("@")[0].strip())
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
bullets_size = len(BULLET_PATTERN[bull])
|
|
|
|
|
|
levels: list[list[int]] = [[] for _ in range(bullets_size + 2)]
|
|
|
|
|
|
|
|
|
|
|
|
for i, (text, layout) in enumerate(typed_sections):
|
|
|
|
|
|
for j, patt in enumerate(BULLET_PATTERN[bull]):
|
|
|
|
|
|
if re.match(patt, text.strip()) and is_probable_heading_line(text):
|
|
|
|
|
|
levels[j].append(i)
|
|
|
|
|
|
break
|
|
|
|
|
|
else:
|
|
|
|
|
|
if re.search(r"(title|head)", layout) and not not_title(text):
|
|
|
|
|
|
levels[bullets_size].append(i)
|
|
|
|
|
|
else:
|
|
|
|
|
|
levels[bullets_size + 1].append(i)
|
|
|
|
|
|
|
|
|
|
|
|
pure_sections = [t for t, _ in typed_sections]
|
|
|
|
|
|
|
|
|
|
|
|
def binary_search(arr: list[int], target: int) -> int:
|
|
|
|
|
|
if not arr:
|
|
|
|
|
|
return -1
|
|
|
|
|
|
if target > arr[-1]:
|
|
|
|
|
|
return len(arr) - 1
|
|
|
|
|
|
if target < arr[0]:
|
|
|
|
|
|
return -1
|
|
|
|
|
|
|
|
|
|
|
|
s, e = 0, len(arr)
|
|
|
|
|
|
while e - s > 1:
|
|
|
|
|
|
mid = (e + s) // 2
|
|
|
|
|
|
if target > arr[mid]:
|
|
|
|
|
|
s = mid
|
|
|
|
|
|
elif target < arr[mid]:
|
|
|
|
|
|
e = mid
|
|
|
|
|
|
else:
|
|
|
|
|
|
return mid
|
|
|
|
|
|
return s
|
|
|
|
|
|
|
|
|
|
|
|
cks: list[list[int]] = []
|
|
|
|
|
|
readed = [False] * len(pure_sections)
|
|
|
|
|
|
levels = list(reversed(levels))
|
|
|
|
|
|
for i, arr in enumerate(levels[:depth]):
|
|
|
|
|
|
for j in arr:
|
|
|
|
|
|
if readed[j]:
|
|
|
|
|
|
continue
|
|
|
|
|
|
readed[j] = True
|
|
|
|
|
|
cks.append([j])
|
|
|
|
|
|
if i + 1 == len(levels) - 1:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
for ii in range(i + 1, len(levels)):
|
|
|
|
|
|
jj = binary_search(levels[ii], j)
|
|
|
|
|
|
if jj < 0:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if levels[ii][jj] > cks[-1][-1]:
|
|
|
|
|
|
cks[-1].pop(-1)
|
|
|
|
|
|
cks[-1].append(levels[ii][jj])
|
|
|
|
|
|
|
|
|
|
|
|
for ii in cks[-1]:
|
|
|
|
|
|
readed[ii] = True
|
|
|
|
|
|
|
|
|
|
|
|
if not cks:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
for i in range(len(cks)):
|
|
|
|
|
|
cks[i] = [pure_sections[j] for j in reversed(cks[i])]
|
|
|
|
|
|
|
|
|
|
|
|
res: list[list[str]] = [[]]
|
|
|
|
|
|
num = [0]
|
|
|
|
|
|
for ck in cks:
|
|
|
|
|
|
if len(ck) == 1:
|
|
|
|
|
|
n = count_tokens(re.sub(r"@@[0-9]+.*", "", ck[0]))
|
|
|
|
|
|
if n + num[-1] < 218:
|
|
|
|
|
|
res[-1].append(ck[0])
|
|
|
|
|
|
num[-1] += n
|
|
|
|
|
|
continue
|
|
|
|
|
|
res.append(ck)
|
|
|
|
|
|
num.append(n)
|
|
|
|
|
|
continue
|
|
|
|
|
|
res.append(ck)
|
|
|
|
|
|
num.append(218)
|
|
|
|
|
|
|
|
|
|
|
|
return [chunk for chunk in res if chunk]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _remove_pdf_tags(text: str) -> str:
|
|
|
|
|
|
return re.sub(r"@@[0-9-]+\t[0-9.\t]+##", "", text or "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_custom_delimiters(delimiter: str) -> list[str]:
|
|
|
|
|
|
return [m.group(1) for m in re.finditer(r"`([^`]+)`", delimiter or "")]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def naive_merge(
|
|
|
|
|
|
sections: str | list[str] | list[tuple[str, str]],
|
|
|
|
|
|
chunk_token_num: int = 128,
|
|
|
|
|
|
delimiter: str = "\n。;!?",
|
|
|
|
|
|
overlapped_percent: int = 0,
|
|
|
|
|
|
) -> list[str]:
|
|
|
|
|
|
if not sections:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(sections, str):
|
|
|
|
|
|
typed_sections: list[tuple[str, str]] = [(sections, "")]
|
|
|
|
|
|
elif isinstance(sections[0], str):
|
|
|
|
|
|
typed_sections = [(s, "") for s in sections] # type: ignore[index]
|
|
|
|
|
|
else:
|
|
|
|
|
|
typed_sections = sections # type: ignore[assignment]
|
|
|
|
|
|
|
|
|
|
|
|
chunk_token_num = max(int(chunk_token_num or 0), 0)
|
|
|
|
|
|
overlap = max(0, min(int(overlapped_percent or 0), 99))
|
|
|
|
|
|
|
|
|
|
|
|
custom_delimiters = _extract_custom_delimiters(delimiter)
|
|
|
|
|
|
if custom_delimiters:
|
|
|
|
|
|
pattern = "|".join(re.escape(t) for t in sorted(set(custom_delimiters), key=len, reverse=True))
|
|
|
|
|
|
chunks: list[str] = []
|
|
|
|
|
|
for sec, pos in typed_sections:
|
|
|
|
|
|
split_sec = re.split(rf"({pattern})", sec, flags=re.DOTALL)
|
|
|
|
|
|
for sub in split_sec:
|
|
|
|
|
|
if re.fullmatch(pattern, sub or ""):
|
|
|
|
|
|
continue
|
|
|
|
|
|
text = "\n" + sub
|
|
|
|
|
|
local_pos = pos if count_tokens(text) >= 8 else ""
|
|
|
|
|
|
if local_pos and local_pos not in text:
|
|
|
|
|
|
text += local_pos
|
|
|
|
|
|
if text.strip():
|
|
|
|
|
|
chunks.append(text)
|
|
|
|
|
|
return chunks
|
|
|
|
|
|
|
|
|
|
|
|
if chunk_token_num <= 0:
|
|
|
|
|
|
merged = "\n".join(sec for sec, _ in typed_sections if sec and sec.strip())
|
|
|
|
|
|
return [merged] if merged.strip() else []
|
|
|
|
|
|
|
|
|
|
|
|
chunks = [""]
|
|
|
|
|
|
token_nums = [0]
|
|
|
|
|
|
|
|
|
|
|
|
def add_chunk(text: str, pos: str) -> None:
|
|
|
|
|
|
tnum = count_tokens(text)
|
|
|
|
|
|
local_pos = pos or ""
|
|
|
|
|
|
if tnum < 8:
|
|
|
|
|
|
local_pos = ""
|
|
|
|
|
|
|
|
|
|
|
|
threshold = chunk_token_num * (100 - overlap) / 100.0
|
|
|
|
|
|
if chunks[-1] == "" or token_nums[-1] > threshold:
|
|
|
|
|
|
if chunks:
|
|
|
|
|
|
prev = _remove_pdf_tags(chunks[-1])
|
|
|
|
|
|
start = int(len(prev) * (100 - overlap) / 100.0)
|
|
|
|
|
|
text = prev[start:] + text
|
|
|
|
|
|
if local_pos and local_pos not in text:
|
|
|
|
|
|
text += local_pos
|
|
|
|
|
|
chunks.append(text)
|
|
|
|
|
|
token_nums.append(tnum)
|
|
|
|
|
|
else:
|
|
|
|
|
|
if local_pos and local_pos not in chunks[-1]:
|
|
|
|
|
|
text += local_pos
|
|
|
|
|
|
chunks[-1] += text
|
|
|
|
|
|
token_nums[-1] += tnum
|
|
|
|
|
|
|
|
|
|
|
|
for sec, pos in typed_sections:
|
|
|
|
|
|
if not sec:
|
|
|
|
|
|
continue
|
|
|
|
|
|
add_chunk("\n" + sec, pos)
|
|
|
|
|
|
|
|
|
|
|
|
return [chunk for chunk in chunks if chunk.strip()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Node:
|
|
|
|
|
|
level: int
|
|
|
|
|
|
depth: int = -1
|
|
|
|
|
|
texts: list[str] = field(default_factory=list)
|
2026-03-04 04:13:05 +08:00
|
|
|
|
children: list[Node] = field(default_factory=list)
|
2026-02-20 19:35:43 +08:00
|
|
|
|
|
2026-03-04 04:13:05 +08:00
|
|
|
|
def add_child(self, child_node: Node) -> None:
|
2026-02-20 19:35:43 +08:00
|
|
|
|
self.children.append(child_node)
|
|
|
|
|
|
|
|
|
|
|
|
def add_text(self, text: str) -> None:
|
|
|
|
|
|
self.texts.append(text)
|
|
|
|
|
|
|
2026-03-04 04:13:05 +08:00
|
|
|
|
def build_tree(self, lines: list[tuple[int, str]]) -> Node:
|
2026-02-20 19:35:43 +08:00
|
|
|
|
stack: list[Node] = [self]
|
|
|
|
|
|
for level, text in lines:
|
|
|
|
|
|
if self.depth != -1 and level > self.depth:
|
|
|
|
|
|
stack[-1].add_text(text)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
while len(stack) > 1 and level <= stack[-1].level:
|
|
|
|
|
|
stack.pop()
|
|
|
|
|
|
|
|
|
|
|
|
node = Node(level=level, texts=[text])
|
|
|
|
|
|
stack[-1].add_child(node)
|
|
|
|
|
|
stack.append(node)
|
|
|
|
|
|
|
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
|
|
def get_tree(self) -> list[str]:
|
|
|
|
|
|
tree_list: list[str] = []
|
|
|
|
|
|
self._dfs(self, tree_list, [])
|
|
|
|
|
|
return tree_list
|
|
|
|
|
|
|
2026-03-04 04:13:05 +08:00
|
|
|
|
def _dfs(self, node: Node, tree_list: list[str], titles: list[str]) -> None:
|
2026-02-20 19:35:43 +08:00
|
|
|
|
level = node.level
|
|
|
|
|
|
texts = node.texts
|
|
|
|
|
|
child = node.children
|
|
|
|
|
|
|
|
|
|
|
|
if level == 0 and texts:
|
|
|
|
|
|
tree_list.append("\n".join(titles + texts))
|
|
|
|
|
|
|
|
|
|
|
|
path_titles = titles + texts if 1 <= level <= self.depth else titles
|
|
|
|
|
|
|
|
|
|
|
|
if level > self.depth and texts:
|
|
|
|
|
|
tree_list.append("\n".join(path_titles + texts))
|
|
|
|
|
|
elif not child and (1 <= level <= self.depth):
|
|
|
|
|
|
tree_list.append("\n".join(path_titles))
|
|
|
|
|
|
|
|
|
|
|
|
for c in child:
|
|
|
|
|
|
self._dfs(c, tree_list, path_titles)
|