183 lines
7.4 KiB
Python
183 lines
7.4 KiB
Python
"""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
|