Files

183 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""RAG 管道:文件解析"""
import json
import re
import unicodedata
from pathlib import Path
# 口语稿和字幕里常见的填充词。只在句子边界作为独立词出现时删除,
# 避免把“然后我们……”这类有实际语义的连接词误删。
_FILLER_WORDS = r"嗯|呃|额|啊|哦|唉|那个|这个嘛|就是嘛"
_FILLER_TOKEN_RE = re.compile(
rf"(?:(?<=^)|(?<=[\s,。!?;:,.!?;:、]))(?:{_FILLER_WORDS})+(?=(?:[\s,。!?;:,.!?;:、]|$))"
)
_TIMESTAMP_RE = re.compile(
r"^(?:\d{1,4}|\[?\d{1,2}:\d{2}(?::\d{2})?(?:[,.]\d{1,3})?\]?"
r"|\d{1,2}:\d{2}(?::\d{2})?(?:[,.]\d{1,3})?\s*[-–—>]\s*.+)$"
)
def clean_text(value: str, *, remove_fillers: bool = True) -> str:
"""清理导入素材中的格式噪声,同时保留有业务意义的数字。
只删除整行序号/时间戳、不可见控制字符、重复空白和句末重复标点;
数字出现在正文中(如价格、日期、型号)时会原样保留。知识库默认去除
句首独立口头填充词;数字人蒸馏可传 ``remove_fillers=False`` 保留口吻。
"""
if not isinstance(value, str):
value = str(value or "")
text = unicodedata.normalize("NFKC", value)
text = text.replace("\uFEFF", "").replace("\u200B", "").replace("\u200C", "").replace("\u200D", "")
# 保留换行和制表,移除其它 C0/C1 控制字符。
text = "".join(ch for ch in text if ch in "\n\r\t" or not unicodedata.category(ch).startswith("C"))
lines: list[str] = []
for raw_line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
line = re.sub(r"[ \t]+", " ", raw_line).strip()
if not line:
continue
# 字幕编号、单独页码、时间戳行是结构噪声;正文中的数字不受影响。
if _TIMESTAMP_RE.fullmatch(line):
continue
if remove_fillers:
line = _FILLER_TOKEN_RE.sub("", line)
line = re.sub(r"^[、,,;;::\s]+", "", line)
# 仅压缩明显的重复标点,不改写省略号和正文标点风格。
line = re.sub(r"([。!?!?;;])\1+", r"\1", line)
if line:
lines.append(line)
return "\n".join(lines).strip()
def parse_file(path: Path) -> list[dict]:
"""返回 [{text, meta:{...}}] 按文件内章节/段落拆分的最初单元"""
ext = path.suffix.lower()
if ext == ".docx":
return _parse_docx(path)
if ext == ".srt":
return _parse_srt(path)
if ext == ".pdf":
return _parse_pdf(path)
if ext == ".json" or ext == ".jsonl":
return _parse_json(path)
if ext == ".md":
return _parse_text(path, is_md=True)
if ext in (".txt", ""):
return _parse_text(path)
return []
def _parse_pdf(path: Path) -> list[dict]:
"""PDF 文本层解析;扫描版无文本时返回空,避免把页码等结构噪声写入索引。"""
try:
from pypdf import PdfReader
reader = PdfReader(str(path))
except Exception:
return []
out: list[dict] = []
for page_no, page in enumerate(reader.pages, 1):
text = clean_text(page.extract_text() or "")
if text:
out.append({"text": text, "meta": {"page": page_no}})
return out
def _parse_docx(path: Path) -> list[dict]:
try:
from docx import Document
except ImportError:
return []
doc = Document(str(path))
out, buf, cur_h = [], [], ""
for p in doc.paragraphs:
t = clean_text(p.text)
if not t:
continue
if p.style and p.style.name and "Heading" in p.style.name:
if buf:
out.append({"text": clean_text("\n".join(buf)), "meta": {"heading": cur_h}})
buf = []
cur_h = t
else:
buf.append(t)
if buf:
out.append({"text": clean_text("\n".join(buf)), "meta": {"heading": cur_h}})
return out
def _parse_srt(path: Path) -> list[dict]:
"""SRT:去时间戳,按时间戳间隙合并成语义段(间隙>2秒 或 字幕号跳变)"""
raw = path.read_text(encoding="utf-8", errors="ignore")
blocks = re.split(r"\n\s*\n", raw)
cur, cur_end, out = [], None, []
for b in blocks:
lines = [l.strip() for l in b.splitlines() if l.strip()]
if not lines:
continue
# 行1=序号,行2=时间 00:00:01,000 --> 00:00:03,500
if len(lines) >= 2 and "-->" in lines[1]:
m = re.match(r"(\d+):(\d+):(\d+),(\d+)", lines[1])
start = int(m.group(1)) * 3600 + int(m.group(2)) * 60 + int(m.group(3)) if m else 0
gap = (start - cur_end) if cur_end is not None else 0
text = clean_text(" ".join(lines[2:]))
if cur and gap > 2.0:
out.append({"text": clean_text(" ".join(cur)), "meta": {}})
cur = []
cur.append(text)
cur_end = start
if cur:
out.append({"text": clean_text(" ".join(cur)), "meta": {}})
return out
def _parse_json(path: Path) -> list[dict]:
raw = path.read_text(encoding="utf-8", errors="ignore")
out = []
# JSONL:逐行 JSON(观品 chunks.jsonl 格式)
for line in raw.splitlines():
line = line.strip()
if not line:
continue
try:
item = json.loads(line)
except Exception:
continue
if isinstance(item, dict):
text = item.get("content") or item.get("text") or item.get("page_content")
if text:
text = clean_text(str(text))
if " " in text:
text = text.replace(" ", "") # 观品格式字间空格
out.append({"text": text, "meta": {"heading": item.get("title", "")}})
if out:
return out
data = json.loads(raw)
if isinstance(data, list):
for i, item in enumerate(data):
text = item.get("content") or item.get("text") or (item.get("page_content") if isinstance(item, dict) else str(item))
if text:
out.append({"text": clean_text(str(text)), "meta": {"index": i}})
elif isinstance(data, dict):
# 支持 {title: text} 或 {chunks: [...]}
for k, v in data.items():
if isinstance(v, str) and v.strip():
out.append({"text": clean_text(v), "meta": {"heading": clean_text(k, remove_fillers=False)}})
elif isinstance(v, list):
for item in v:
t = item.get("content") or item.get("text") if isinstance(item, dict) else str(item)
if t:
out.append({"text": clean_text(str(t)), "meta": {"heading": clean_text(k, remove_fillers=False)}})
return out
def _parse_text(path: Path, is_md: bool = False) -> list[dict]:
out, buf, cur_h = [], [], ""
# 按行读取,避免公网分片上传后的超大文本一次性进入内存。
with path.open("r", encoding="utf-8", errors="ignore") as source:
for line in source:
s = clean_text(line)
if not s:
continue
if is_md and (s.startswith("# ") or s.startswith("## ")):
if buf:
out.append({"text": clean_text("\n".join(buf)), "meta": {"heading": cur_h}})
buf = []
cur_h = s.lstrip("#").strip()
else:
buf.append(s)
if buf:
out.append({"text": clean_text("\n".join(buf)), "meta": {"heading": cur_h}})
return out