ReMe/reme2/component/file_parser/linked_file_parser.py
huangsen 51ec09d98f feat(parser): implement AST-based markdown chunking with full document TOC
- Replace legacy line-based chunking with AST tree approach that builds
  a complete document skeleton with content inlined under relevant
  sections
- Add new chunking parameters: chunk_chars (default 2000) and embed_toc
  (default True) to control content size and TOC inclusion
- Implement recursive chunking algorithm that respects structural
  boundaries (code lines, table rows, list items) and prevents splits
  inside blocks
- Introduce part markers [Part X/N] for oversized leaf blocks that
  require splitting
- Add CLI tool for inspecting parsed chunks and edges with options for
  preview and configuration
- Refactor edge extraction to use FileEdge.from_text instead of
  parse_wikilinks for consistency

BREAKING CHANGE: Chunk format changes significantly with full TOC
skeleton wrapping content, affecting embedding models expecting
breadcrumb prefixes.
2026-05-13 14:32:00 +08:00

795 lines
27 KiB
Python

"""Markdown file parser — frontmatter + wikilink graph + AST tree chunks.
Chunking algorithm (full-doc skeleton + inlined content)
========================================================
Each chunk renders the **complete heading skeleton of the entire
document**, with this chunk's content inlined under the section that
owns it. Sections that don't own this chunk's content appear as bare
headings — every chunk gives the reader a complete map of the document
and shows exactly where its slice belongs.
Two phases:
1. **Build phase** — fold mistletoe's flat `Document.children` into a
layered `MdNode` tree. A `section` node owns its heading + body
blocks + child subsections, established by a heading-level stack.
2. **Chunk phase** — recursive `chunk(node, parent_section)`:
if len(content) <= chunk_chars:
emit one chunk (TOC wrapped on top, additive to size) and return
if node is section/root:
walk children — body siblings pack as a run inside the
current section's TOC slot, subsections recurse
else (leaf body):
split by internal structure (List items / Table rows /
code lines / paragraph lines)
Example — doc with sections A, B (containing B1), C — chunking content
of B1 produces a chunk like:
# Doc
## A
## B
### B1
<chunk content>
## C
The current section (here `### B1`) is the **owner**: its slot holds
the chunk's body. All other sections appear as bare headings.
**Budget rule**: ``chunk_chars`` constrains the **content** only —
the TOC skeleton is added as a free prefix on top of every chunk and
does NOT count toward the budget. This keeps content sizing predictable
even when a doc has a large heading outline.
**Toggle**: ``embed_toc=False`` disables the TOC wrap entirely; chunks
become plain content with no document-level navigation prefix. The
chunking decisions themselves are unchanged — only the final emitted
text differs.
Section integrity: a node is split only when it cannot fit as a whole.
Block-internal structure (code lines, table rows, list items) is
respected — splits land on those boundaries, never inside.
**Part markers**: when a single leaf block (table / code fence / list /
paragraph) is too large to fit and gets split into N > 1 pieces, each
piece is annotated with a ``[Part X/N]\\n\\n`` prefix so readers know
they're seeing a fragment. Single-piece outputs are unmarked.
Chunk identity: `hash_text(path::start::end::text)` — content-deterministic
so the file_store's hash-diff cache hits across re-parses of unchanged
sections.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import frontmatter
from mistletoe.block_token import Document
from mistletoe.markdown_renderer import MarkdownRenderer
from .base_file_parser import BaseFileParser
from ..component_registry import R
from ...enumeration import FileSuffixEnum
from ...schema import FileChunk, FileEdge, FileNode
from ...utils import hash_text
# -- Helpers --------------------------------------------------------------
def _kind(node) -> str:
return type(node).__name__
def _is_heading(node) -> bool:
return _kind(node) in ("Heading", "SetextHeading")
def _line_count(text: str) -> int:
return len(text.split("\n")) if text else 0
def _heading_text(node, renderer: MarkdownRenderer) -> str:
"""Heading text without `#` markers (for outline)."""
rendered = renderer.render(node).rstrip("\n")
if rendered.startswith("#"):
return rendered.lstrip("#").strip()
return rendered.split("\n", 1)[0].strip()
# Reserved overhead for the worst-case "[Part NNN/NNN]\n\n" prefix added
# to leaf-block split pieces. Reserved upfront in budgets so the prefix
# fits even for the chunk that just barely passed the size check.
_PART_MARKER_RESERVE = 18
# -- AST tree -------------------------------------------------------------
@dataclass
class MdNode:
"""Composed markdown AST node.
Three synthesised kinds wrap mistletoe blocks into a layered tree:
* ``root`` — sole top-level node; ``children`` are bodies and/or
sections; carries no heading.
* ``section`` — synthesised from a heading + everything beneath it
until the next equal-or-shallower heading; children
are bodies and child sections.
* ``body`` — wraps one mistletoe block (paragraph / list / table /
code / quote / html / etc.); ``block`` is the
original mistletoe node, ``text`` is its rendered
markdown.
Ranges (`start_line`, `end_line`) span the full subtree so callers can
record provenance on emitted chunks.
"""
kind: str # "root" | "section" | "body"
heading: str | None = None
level: int = 0
children: list["MdNode"] = field(default_factory=list)
block: Any = None
text: str = ""
start_line: int = 0
end_line: int = 0
@R.register("md")
class LinkedFileParser(BaseFileParser):
"""Markdown parser: frontmatter + wikilink edges + full-skeleton chunks."""
suffixes = [FileSuffixEnum.MD, FileSuffixEnum.MARKDOWN]
def __init__(
self,
encoding: str = "utf-8",
chunk_chars: int = 2000,
embed_toc: bool = True,
**kwargs,
):
super().__init__(**kwargs)
self.encoding = encoding
self.chunk_chars = max(100, chunk_chars)
self.embed_toc = embed_toc
async def parse(self, path: str | Path) -> tuple[FileNode, list[FileChunk]]:
file_path = Path(path)
raw = file_path.read_text(encoding=self.encoding)
post = frontmatter.loads(raw)
stat = file_path.stat()
absolute_path = str(file_path.absolute())
edges = self._dedup_edges(FileEdge.from_text(post.content))
chunks = self._chunk(post.content, absolute_path)
node = FileNode(
path=absolute_path,
st_mtime=stat.st_mtime,
edges=edges,
**dict(post.metadata),
)
return node, chunks
@staticmethod
def _dedup_edges(edges: list[FileEdge]) -> list[FileEdge]:
seen: set[tuple] = set()
out: list[FileEdge] = []
for e in edges:
key = (e.link, e.predicate)
if key in seen:
continue
seen.add(key)
out.append(e)
return out
# -- Chunker entry ---------------------------------------------------
def _chunk(self, text: str, path: str) -> list[FileChunk]:
if not text or not text.strip():
return []
out: list[FileChunk] = []
with MarkdownRenderer() as renderer:
doc = Document(text)
tree = self._build_tree(doc, renderer)
self._chunk_tree(tree, tree, tree, renderer, path, out)
return out
# -- Build phase: mistletoe doc → MdNode tree -------------------------
def _build_tree(self, doc, renderer: MarkdownRenderer) -> MdNode:
"""Fold mistletoe's flat children into a section tree.
Algorithm: walk children with a stack of open sections. Each
heading pops sections of equal-or-deeper level and pushes a new
section. Non-heading blocks attach as ``body`` children to the
current section (or to root before the first heading).
"""
root = MdNode(kind="root", level=0, start_line=1, end_line=1)
stack: list[MdNode] = [root]
for child in doc.children or []:
kind = _kind(child)
if kind == "BlankLine":
continue
if _is_heading(child):
level = max(1, getattr(child, "level", 1))
# Close any sections of equal-or-greater level.
while len(stack) > 1 and stack[-1].level >= level:
stack.pop()
sec = MdNode(
kind="section",
heading=_heading_text(child, renderer),
level=level,
start_line=child.line_number or stack[-1].start_line,
)
stack[-1].children.append(sec)
stack.append(sec)
else:
rendered = renderer.render(child).rstrip("\n")
if not rendered:
continue
start = child.line_number or stack[-1].start_line
body = MdNode(
kind="body",
block=child,
text=rendered,
start_line=start,
end_line=start + _line_count(rendered) - 1,
)
stack[-1].children.append(body)
# Propagate end_line bottom-up.
def _close(n: MdNode) -> None:
if not n.children:
if n.end_line < n.start_line:
n.end_line = n.start_line
return
for c in n.children:
_close(c)
n.end_line = max(c.end_line for c in n.children)
n.start_line = min(n.start_line or n.children[0].start_line, n.children[0].start_line)
_close(root)
return root
# -- Chunk phase: recursive ------------------------------------------
def _chunk_tree(
self,
tree: MdNode,
node: MdNode,
parent_section: MdNode,
renderer: MarkdownRenderer,
file_path: str,
out: list[FileChunk],
) -> None:
"""Try the whole subtree first; on overflow descend into children.
``tree`` is the whole-document root used to render the full TOC
skeleton (when ``embed_toc`` is on). ``parent_section`` is the
nearest enclosing section/root — for body nodes it's the slot
owner; for sections it becomes the slot owner when the section
itself fits whole. The ``chunk_chars`` budget only constrains
``content`` — TOC overhead is excluded.
"""
if node.kind == "body":
owner = parent_section
content = node.text
owns_subtree = False
else: # root or section
owner = node
content = self._render_node_content(node)
owns_subtree = True
if not content.strip():
return
if len(content) <= self.chunk_chars:
full = self._finalize(tree, owner, content, owns_subtree)
self._emit(full, node.start_line, node.end_line, file_path, out)
return
if node.kind in ("root", "section"):
self._chunk_children(tree, node, renderer, file_path, out)
else:
self._split_leaf(tree, node, parent_section, renderer, file_path, out)
def _chunk_children(
self,
tree: MdNode,
parent: MdNode,
renderer: MarkdownRenderer,
file_path: str,
out: list[FileChunk],
) -> None:
"""Walk a section's children: body run packs together, each
subsection recurses. Body runs use ``parent`` as TOC owner."""
run: list[MdNode] = []
def flush_run() -> None:
nonlocal run
if run:
self._chunk_body_run(tree, run, parent, renderer, file_path, out)
run = []
for c in parent.children:
if c.kind == "section":
flush_run()
self._chunk_tree(tree, c, parent, renderer, file_path, out)
else:
run.append(c)
flush_run()
def _chunk_body_run(
self,
tree: MdNode,
run: list[MdNode],
owner: MdNode,
renderer: MarkdownRenderer,
file_path: str,
out: list[FileChunk],
) -> None:
"""A run of consecutive body siblings sharing ``owner``'s slot.
Try whole run first; on overflow greedy-pack into ``chunk_chars``
(content-only budget); an oversized single body recurses through
``_split_leaf``.
"""
composite = "\n\n".join(b.text for b in run)
if len(composite) <= self.chunk_chars:
full = self._finalize(tree, owner, composite, owns_subtree=False)
self._emit(full, run[0].start_line, run[-1].end_line, file_path, out)
return
budget = self.chunk_chars
bucket: list[MdNode] = []
bucket_chars = 0
def flush() -> None:
nonlocal bucket, bucket_chars
if not bucket:
return
text = "\n\n".join(b.text for b in bucket)
piece = self._finalize(tree, owner, text, owns_subtree=False)
self._emit(piece, bucket[0].start_line, bucket[-1].end_line, file_path, out)
bucket = []
bucket_chars = 0
for body in run:
if len(body.text) > budget:
flush()
self._split_leaf(tree, body, owner, renderer, file_path, out)
continue
sep = 2 if bucket else 0 # "\n\n"
if bucket and bucket_chars + sep + len(body.text) > budget:
flush()
sep = 0
bucket.append(body)
bucket_chars += sep + len(body.text)
flush()
# -- Leaf-internal splitters -----------------------------------------
def _split_leaf(
self,
tree: MdNode,
body: MdNode,
owner: MdNode,
renderer: MarkdownRenderer,
file_path: str,
out: list[FileChunk],
) -> None:
kind = _kind(body.block) if body.block is not None else "Paragraph"
if kind == "Table":
self._split_table(tree, body, owner, file_path, out)
elif kind == "CodeFence":
self._split_code(tree, body, owner, file_path, out)
elif kind == "List":
self._split_list(tree, body, owner, renderer, file_path, out)
else:
self._split_lines(tree, body, owner, file_path, out)
def _split_table(
self,
tree: MdNode,
body: MdNode,
owner: MdNode,
file_path: str,
out: list[FileChunk],
) -> None:
"""Split a table by data rows; repeat header + separator per piece."""
table = body.block
rendered = body.text
all_lines = rendered.split("\n")
header = "\n".join(all_lines[:2])
data_lines = all_lines[2:]
rows = [r for r in (table.children or []) if _kind(r) == "TableRow"]
start = body.start_line
if len(rows) == len(data_lines):
units = [
(data_lines[i],
rows[i].line_number or (start + 2 + i),
rows[i].line_number or (start + 2 + i))
for i in range(len(data_lines))
]
else:
units = [
(data_lines[i], start + 2 + i, start + 2 + i)
for i in range(len(data_lines))
]
self._emit_packed(
tree, owner, units, joiner="\n",
wrap=f"{header}\n{{inner}}", file_path=file_path, out=out,
)
def _split_code(
self,
tree: MdNode,
body: MdNode,
owner: MdNode,
file_path: str,
out: list[FileChunk],
) -> None:
"""Split a code fence by body lines; repeat opener / closer per piece."""
code = body.block
indent = " " * (code.indentation or 0)
info = code.info_string or ""
opener = f"{indent}{code.delimiter}{info}"
closer = f"{indent}{code.delimiter}"
wrap = f"{opener}\n{{inner}}\n{closer}"
raw = (code.children[0].content if code.children else "").rstrip("\n")
if not raw:
return
start = body.start_line + 1
units = [
(indent + ln, start + i, start + i)
for i, ln in enumerate(raw.split("\n"))
]
self._emit_packed(
tree, owner, units, joiner="\n", wrap=wrap,
file_path=file_path, out=out, allow_empty=True,
)
def _split_list(
self,
tree: MdNode,
body: MdNode,
owner: MdNode,
renderer: MarkdownRenderer,
file_path: str,
out: list[FileChunk],
) -> None:
"""Split a list by items; greedy-pack items.
Multi-part splits annotate each piece with ``[Part X/N]``
(see ``_emit_parts``). An item that exceeds the budget on its
own is emitted as one part (continuity wins over hard cap).
"""
items = [c for c in (body.block.children or []) if _kind(c) == "ListItem"]
if not items:
self._split_lines(tree, body, owner, file_path, out)
return
budget = max(64, self.chunk_chars - _PART_MARKER_RESERVE)
rendered_items = [
(renderer.render(it).rstrip("\n"), it.line_number or body.start_line)
for it in items
]
parts: list[tuple[str, int, int]] = []
bucket: list[tuple[str, int, int]] = []
bucket_chars = 0
def flush() -> None:
nonlocal bucket, bucket_chars
if not bucket:
return
text = "\n".join(t for t, _, _ in bucket)
parts.append((text, bucket[0][1], bucket[-1][2]))
bucket = []
bucket_chars = 0
for text, line in rendered_items:
if not text:
continue
end = line + _line_count(text) - 1
if len(text) > budget:
# Oversized item: emit alone (overflow accepted).
flush()
parts.append((text, line, end))
continue
sep = 1 if bucket else 0 # "\n"
if bucket and bucket_chars + sep + len(text) > budget:
flush()
sep = 0
bucket.append((text, line, end))
bucket_chars += sep + len(text)
flush()
self._emit_parts(tree, owner, parts, wrap="{inner}", file_path=file_path, out=out)
def _split_lines(
self,
tree: MdNode,
body: MdNode,
owner: MdNode,
file_path: str,
out: list[FileChunk],
) -> None:
"""Last-resort split: line-greedy. Used for paragraphs / quotes /
html / oversized list items."""
start = body.start_line
units = [
(line, start + i, start + i)
for i, line in enumerate(body.text.split("\n"))
]
self._emit_packed(
tree, owner, units, joiner="\n", wrap="{inner}",
file_path=file_path, out=out,
)
def _emit_packed(
self,
tree: MdNode,
owner: MdNode,
units: list[tuple[str, int, int]],
joiner: str,
wrap: str,
file_path: str,
out: list[FileChunk],
allow_empty: bool = False,
) -> None:
"""Greedy-pack units into the wrap envelope; emit each bucket
as a chunk via ``_emit_parts`` (which adds ``[Part X/N]`` when
more than one piece results).
The envelope (e.g. table header, code fence) IS counted against
``chunk_chars`` because it's part of the chunk's content. The
TOC skeleton, when ``embed_toc`` is on, is added as a free
prefix downstream.
A unit larger than the inner budget is emitted alone (continuity
wins over hard cap — readers can still see the overflowing line).
"""
envelope = len(wrap.replace("{inner}", ""))
inner_budget = max(64, self.chunk_chars - envelope - _PART_MARKER_RESERVE)
sep_len = len(joiner)
parts: list[tuple[str, int, int]] = []
bucket: list[tuple[str, int, int]] = []
bucket_chars = 0
def flush() -> None:
nonlocal bucket, bucket_chars
if not bucket:
return
inner = joiner.join(t for t, _, _ in bucket)
parts.append((inner, bucket[0][1], bucket[-1][2]))
bucket = []
bucket_chars = 0
for text, s, e in units:
if not text and not allow_empty:
continue
sep = sep_len if bucket else 0
if bucket and bucket_chars + sep + len(text) > inner_budget:
flush()
sep = 0
bucket.append((text, s, e))
bucket_chars += sep + len(text)
flush()
self._emit_parts(tree, owner, parts, wrap=wrap, file_path=file_path, out=out)
def _emit_parts(
self,
tree: MdNode,
owner: MdNode,
parts: list[tuple[str, int, int]],
wrap: str,
file_path: str,
out: list[FileChunk],
) -> None:
"""Emit a list of leaf-block split parts.
Each part is a ``(inner_text, start_line, end_line)`` triple.
``wrap`` is a format string with ``{inner}`` substituted per
piece (e.g. ``"| header |\\n{inner}"`` for tables; ``"{inner}"``
for plain line splits).
When ``len(parts) > 1`` each piece is prefixed with
``[Part X/N]\\n\\n`` so readers know it's a fragment of a
larger leaf block. A single part emits with no marker.
"""
total = len(parts)
for idx, (inner, s, e) in enumerate(parts, 1):
piece = wrap.replace("{inner}", inner)
if total > 1:
piece = f"[Part {idx}/{total}]\n\n{piece}"
full = self._finalize(tree, owner, piece, owns_subtree=False)
self._emit(full, s, e, file_path, out)
# -- Render helpers ---------------------------------------------------
def _finalize(
self,
tree: MdNode,
owner: MdNode,
content: str,
owns_subtree: bool,
) -> str:
"""Produce the final chunk text from raw ``content``.
When ``embed_toc`` is on (default), wrap content with the full
document heading skeleton (see ``_render_with_full_toc``) so the
chunk shows where its slice belongs in the doc. When off, return
``content`` unchanged — the chunk is just its own text.
``chunk_chars`` is checked against ``content`` upstream of this
call; the TOC skeleton is *additive* to the chunk text and does
not consume the budget. Callers wanting to know the final chunk
length must call ``len(self._finalize(...))`` themselves.
"""
if not self.embed_toc:
return content
return self._render_with_full_toc(tree, owner, content, owns_subtree)
@classmethod
def _render_with_full_toc(
cls,
tree: MdNode,
owner: MdNode,
content: str,
owns_subtree: bool,
) -> str:
"""Render the full document heading skeleton with ``content``
inlined under ``owner``'s heading.
Walks the entire tree. Every section emits its heading; only the
``owner`` slot also emits ``content``. If ``owns_subtree`` is
True, recursion stops at ``owner`` (its subsection headings are
assumed already present in ``content``); otherwise traversal
continues so descendant headings still appear in the TOC.
Bodies are never emitted by this walk — they're brought in only
via ``content``. ``owner`` may be the root node, in which case
the content sits before the first heading.
"""
lines: list[str] = []
def walk(node: MdNode) -> None:
if node.kind == "section" and node.heading is not None:
lines.append(f"{'#' * max(1, node.level)} {node.heading}")
if node is owner:
if content:
lines.append(content)
if owns_subtree:
return
for c in node.children:
if c.kind == "section":
walk(c)
walk(tree)
return "\n\n".join(p for p in lines if p)
@classmethod
def _render_node_content(cls, node: MdNode) -> str:
"""Render a node's content BENEATH its own heading.
The node's own heading is NOT included — when `_render_with_full_toc`
emits the section's TOC entry, the slot it appends ``content`` to
already sits below that heading. Subsection headings ARE included
because they're deeper than the focused node and would otherwise
be swallowed when ``owns_subtree=True``.
"""
if node.kind == "body":
return node.text
parts: list[str] = []
for c in node.children:
if c.kind == "section":
sub_heading = f"{'#' * max(1, c.level)} {c.heading or ''}"
inner = cls._render_node_content(c)
parts.append(sub_heading + ("\n\n" + inner if inner else ""))
else:
if c.text:
parts.append(c.text)
return "\n\n".join(parts)
# -- Emit -------------------------------------------------------------
@staticmethod
def _emit(
text: str, start_line: int, end_line: int, path: str, out: list[FileChunk],
) -> None:
chunk_id = hash_text(f"{path}::{start_line}::{end_line}::{text}")
out.append(FileChunk(
id=chunk_id,
path=path,
start_line=start_line,
end_line=end_line,
text=text,
))
# -- CLI: parse a markdown file and print chunks + edges ------------------
def _main() -> None:
"""Parse a markdown file and print its edges + chunks for inspection.
Usage:
python -m reme2.component.file_parser.linked_file_parser <path> [--chunk-chars N]
"""
import argparse
import asyncio
ap = argparse.ArgumentParser(
description="Parse a markdown file with LinkedFileParser and dump chunks + edges.",
)
ap.add_argument("path", help="Path to a markdown file.")
ap.add_argument(
"--chunk-chars", type=int, default=2000,
help="Max characters per chunk content (default: 2000). "
"Excludes TOC skeleton when embed_toc is on.",
)
ap.add_argument(
"--no-toc", action="store_true",
help="Disable the full-doc TOC skeleton wrap; chunks become plain content.",
)
ap.add_argument(
"--show-edges", action="store_true",
help="Print extracted FileEdges before chunks.",
)
ap.add_argument(
"--preview", type=int, default=0,
help="Truncate each chunk to N chars in output (0 = full text).",
)
args = ap.parse_args()
parser = LinkedFileParser(
chunk_chars=args.chunk_chars,
embed_toc=not args.no_toc,
)
node, chunks = asyncio.run(parser.parse(args.path))
print(f"file: {node.path}")
print(f"chunk_chars: {args.chunk_chars}")
print(f"embed_toc: {parser.embed_toc}")
print(f"chunks: {len(chunks)}")
print(f"chars total: {sum(len(c.text) for c in chunks)}")
if chunks:
sizes = [len(c.text) for c in chunks]
print(f"chars min/avg/max: {min(sizes)} / {sum(sizes)//len(sizes)} / {max(sizes)}")
if args.show_edges:
print(f"\nedges ({len(node.edges)}):")
for e in node.edges:
print(
f"{e.link}"
+ (f" predicate={e.predicate}" if e.predicate else "")
+ (f" anchor={e.anchor}" if e.anchor else "")
)
for i, c in enumerate(chunks):
print(f"\n{'=' * 72}")
print(f"chunk {i} lines {c.start_line}-{c.end_line} {len(c.text)} chars")
print("-" * 72)
text = c.text if args.preview <= 0 else c.text[: args.preview]
print(text)
if args.preview > 0 and len(c.text) > args.preview:
print(f"... ({len(c.text) - args.preview} more chars truncated)")
if __name__ == "__main__":
_main()