Skip to content

Commit 39290dd

Browse files
committed
feat: add reStructuredText support (.rst parser + multi-dialect prose chunker)
Adds end-to-end .rst handling plus the prose-collateral chunker (P0 of docs/research/document_collateral_retrieval_2026.md): - prose_chunker: dialect-agnostic structure-aware parent-child chunker (MarkdownDialect + RstDialect behind a ProseDialect Protocol). Heading- hierarchy sectioning, atomic code/table blocks, hard token bounding, small-sibling merge, faithful line ranges, no-LLM context headers. - rst_parser: RstParser emits DOCUMENT/SECTION graph entities + CONTAINS, mirroring MarkdownParser; adornment headings leveled by encounter order. - wiring: scanner supports .rst; graph_builder routes .rst -> RstParser; parsers package exports RstParser; .rst added to the parser contract. - tests: prose chunker (17), RST dialect (14), RST parser (8); demo script for eyeballing chunk quality on a corpus. - chore: ignore knowledge.db and .DS_Store. Validated on a 915-doc corpus: p50=489 tokens, 80% in-band. 123 tests pass; ruff + mypy clean.
1 parent 1e24fcd commit 39290dd

11 files changed

Lines changed: 1370 additions & 3 deletions

File tree

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,4 +212,8 @@ docs_test/
212212
#aimodels.yaml
213213
.knowcode/
214214
knowcode_index/
215-
215+
knowledge.db
216+
.DS_Store
217+
tests/test_sample-prose
218+
tests/.DS_Store
219+
.gitignore

scripts/prose_chunk_demo.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Demo / profiling runner for the structure-aware prose chunker (P0 prototype).
2+
3+
Runs ``ProseChunker`` over a directory of markdown documents and prints corpus
4+
statistics plus a sample document's chunks, so chunk quality can be eyeballed.
5+
6+
Usage::
7+
8+
uv run python scripts/prose_chunk_demo.py [ROOT] [--show-doc PATH] [--limit N]
9+
10+
Example::
11+
12+
uv run python scripts/prose_chunk_demo.py tests/test_sample-prose
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import statistics as st
18+
from pathlib import Path
19+
20+
import click
21+
22+
from knowcode.indexing.prose_chunker import ProseChunker, ProseChunkingConfig
23+
24+
25+
def _pct(values: list[int], p: float) -> int:
26+
if not values:
27+
return 0
28+
ordered = sorted(values)
29+
return ordered[min(len(ordered) - 1, int(len(ordered) * p))]
30+
31+
32+
def _print_corpus_stats(chunker: ProseChunker, files: list[Path], root: Path) -> Path:
33+
"""Print aggregate stats; return a clean representative doc to display."""
34+
max_tokens = chunker.config.max_tokens
35+
tokens: list[int] = []
36+
per_doc: list[int] = []
37+
flagged = total = 0
38+
sample: Path | None = None
39+
40+
for path in files:
41+
chunks = chunker.chunk_file(path, doc_id=str(path.relative_to(root)))
42+
per_doc.append(len(chunks))
43+
total += len(chunks)
44+
for chunk in chunks:
45+
tokens.append(chunk.token_count)
46+
flagged += chunk.is_oversize
47+
if sample is None and 6 <= len(chunks) <= 14 and not any(c.is_oversize for c in chunks):
48+
sample = path
49+
50+
in_band = sum(1 for t in tokens if 128 <= t <= max_tokens)
51+
click.echo(f"docs={len(files)} total_chunks={total}")
52+
click.echo(
53+
f"chunks/doc: mean={st.mean(per_doc):.1f} p50={_pct(per_doc, .5)} "
54+
f"p90={_pct(per_doc, .9)} max={max(per_doc)}"
55+
)
56+
click.echo(
57+
f"chunk tokens: mean={st.mean(tokens):.0f} p50={_pct(tokens, .5)} "
58+
f"p90={_pct(tokens, .9)} max={max(tokens)}"
59+
)
60+
click.echo(
61+
f"within [128,{max_tokens}]: {in_band / len(tokens) * 100:.1f}% "
62+
f"flagged-oversize: {flagged / total * 100:.1f}% (blob/code that could not split cleanly)"
63+
)
64+
return sample or files[0]
65+
66+
67+
def _print_doc_chunks(chunker: ProseChunker, path: Path, root: Path, limit: int) -> None:
68+
chunks = chunker.chunk_file(path, doc_id=str(path.relative_to(root)))
69+
click.echo(f"\n=== {path.relative_to(root)} ({len(chunks)} chunks) ===")
70+
for chunk in chunks[:limit]:
71+
crumb = " > ".join(chunk.heading_path) or "(preamble)"
72+
flag = " [OVERSIZE]" if chunk.is_oversize else ""
73+
click.echo(
74+
f"\n[L{chunk.level}] {crumb} | lines {chunk.start_line}-{chunk.end_line} "
75+
f"| {chunk.token_count} tok{flag}"
76+
)
77+
click.echo(f" ctx: {chunk.context_header}")
78+
click.echo(f" parent: {chunk.parent_id}")
79+
click.echo(f" text: {chunk.content[:140].replace(chr(10), ' ')}")
80+
81+
82+
@click.command()
83+
@click.argument("root", type=click.Path(exists=True, path_type=Path), default="tests/test_sample-prose")
84+
@click.option("--show-doc", type=click.Path(path_type=Path), default=None, help="Display chunks for one document.")
85+
@click.option("--limit", type=int, default=6, help="Chunks to display for the sample document.")
86+
@click.option("--target-tokens", type=int, default=ProseChunkingConfig.target_tokens)
87+
@click.option("--max-tokens", type=int, default=ProseChunkingConfig.max_tokens)
88+
def main(root: Path, show_doc: Path | None, limit: int, target_tokens: int, max_tokens: int) -> None:
89+
chunker = ProseChunker(ProseChunkingConfig(target_tokens=target_tokens, max_tokens=max_tokens))
90+
if show_doc is not None:
91+
_print_doc_chunks(chunker, show_doc, show_doc.parent, limit)
92+
return
93+
files = sorted(p for p in root.rglob("*.md"))
94+
if not files:
95+
raise click.ClickException(f"no .md files under {root}")
96+
sample = _print_corpus_stats(chunker, files, root)
97+
_print_doc_chunks(chunker, sample, root, limit)
98+
99+
100+
if __name__ == "__main__":
101+
main()

src/knowcode/indexing/graph_builder.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from typing import Optional
66

77
from knowcode.data_models import Entity, ParseResult, Relationship
8-
from knowcode.parsers import MarkdownParser, PythonParser, YamlParser
8+
from knowcode.parsers import MarkdownParser, PythonParser, RstParser, YamlParser
99
from knowcode.parsers.javascript_parser import JavaScriptParser
1010
from knowcode.parsers.typescript_parser import TypeScriptParser
1111
from knowcode.parsers.java_parser import JavaParser
@@ -25,6 +25,7 @@ def __init__(self) -> None:
2525
"""Initialize the graph builder with parsers."""
2626
self.python_parser = PythonParser()
2727
self.markdown_parser = MarkdownParser()
28+
self.rst_parser = RstParser()
2829
self.yaml_parser = YamlParser()
2930
self.js_parser = JavaScriptParser()
3031
self.ts_parser = TypeScriptParser()
@@ -101,6 +102,8 @@ def _parse_file(self, file_info: FileInfo) -> ParseResult:
101102
return self.python_parser.parse_file(file_info.path)
102103
elif file_info.extension == ".md":
103104
return self.markdown_parser.parse_file(file_info.path)
105+
elif file_info.extension == ".rst":
106+
return self.rst_parser.parse_file(file_info.path)
104107
elif file_info.extension in {".yaml", ".yml"}:
105108
return self.yaml_parser.parse_file(file_info.path)
106109
elif file_info.extension in {".js", ".jsx"}:

0 commit comments

Comments
 (0)