"""标题树解析器 从纯文本中解析文档原生标题结构,支持两类模式: - Markdown ATX 标题(# ~ ######,# 数量即层级) - 中文编号标题(第X章/节/篇、一、1.1 等编号,行长度不超过 60 字符) 解析结果用于 L2 大纲生成与 chunk 的 section 切分。 """ import re from pydantic import BaseModel, Field class Heading(BaseModel): """文档标题节点""" title: str = Field(description="标题文本") level: int = Field(description="标题层级,1 为最顶层") line_index: int = Field(description="标题所在行号(从 0 开始)") # Markdown ATX 标题:1~6 个 # 后跟空白 _ATX_PATTERN = re.compile(r"^(#{1,6})\s+(.+?)\s*$") # 中文篇章节编号:第X章/第X节/第X篇(章/篇=1 级,节=2 级) _CN_CHAPTER_PATTERN = re.compile(r"^第[一二三四五六七八九十百\d]+([章节篇])") # 中文序号:一、二、……,固定 1 级 _CN_ENUM_PATTERN = re.compile(r"^[一二三四五六七八九十]+、") # 数字编号:1. / 1、/ 1.1 / 1.1.1 等,按点分段数定层级 _NUM_PATTERN = re.compile(r"^(\d+(?:\.\d+)*)[、.\s]") # 编号类标题行的最大长度,超过则视为正文 _MAX_HEADING_LINE_LENGTH = 60 # 第X[章节篇] 后缀对应的层级 _CN_CHAPTER_LEVELS = {"章": 1, "节": 2, "篇": 1} def parse_headings(text: str) -> list[Heading]: """解析文本中的标题,按行号升序返回 Args: text: 文档纯文本内容 Returns: list[Heading]: 标题列表,无标题时返回空列表 """ headings: list[Heading] = [] for line_index, line in enumerate(text.splitlines()): heading = _match_heading(line, line_index) if heading is not None: headings.append(heading) return headings def render_outline(headings: list[Heading]) -> str: """将标题树渲染为大纲文本,每行一个节点,按层级缩进""" return "\n".join(f"{' ' * (h.level - 1)}- {h.title}" for h in headings) def _match_heading(line: str, line_index: int) -> Heading | None: """匹配单行是否为标题,是则返回 Heading,否则返回 None""" stripped = line.strip() if not stripped: return None # Markdown ATX 标题(无长度限制) match = _ATX_PATTERN.match(stripped) if match: return Heading(title=match.group(2), level=len(match.group(1)), line_index=line_index) # 编号类标题有行长度限制,过长视为正文 if len(stripped) > _MAX_HEADING_LINE_LENGTH: return None # 第X章/节/篇 match = _CN_CHAPTER_PATTERN.match(stripped) if match: return Heading(title=stripped, level=_CN_CHAPTER_LEVELS[match.group(1)], line_index=line_index) # 一、二、…… if _CN_ENUM_PATTERN.match(stripped): return Heading(title=stripped, level=1, line_index=line_index) # 数字编号,层级 = 点分段数(1.=1、1.1=2、1.1.1=3) match = _NUM_PATTERN.match(stripped) if match: level = match.group(1).count(".") + 1 return Heading(title=stripped, level=level, line_index=line_index) return None