Implement domains/notes.py with the NotesDomain class that splits markdown content into semantically coherent chunks with context headers using mistune for AST-based parsing.
Requirements
- Implement
NotesDomain class in domains/notes.py
- Constructor:
__init__(self, soft_limit: int = 512, hard_limit: int = 1024, min_floor: int = 64)
- Implement
chunk(content: NormalizedContent) -> list[Chunk] method:
- Parse markdown to AST using mistune with an AST renderer
- Walk the AST to identify block types: headings (h1–h6), code blocks (fenced), tables, paragraphs
- Split on headings — each heading and its subsequent sibling content until the next same-or-higher-level heading forms a candidate chunk
- Code blocks must be kept atomic (never split mid-block)
- Tables must be kept atomic (never split mid-table)
- Generate context headers as heading breadcrumb paths (e.g.,
"# Architecture > ## Storage > ### SQLite")
- Respect soft and hard token limits: join short adjacent sections below
min_floor; split oversized sections at paragraph boundaries without exceeding hard_limit
- Compute
chunk_hash = SHA-256(normalized_content) for each chunk — content excludes context header; apply whitespace normalization before hashing
- Prepend context header to
content field before constructing Chunk (context header is part of the embedded text but excluded from the hash)
- Assign sequential
chunk_index values
- Return list of
Chunk instances
- Token counting: use whitespace-split word count as proxy (1 word ≈ 1 token) — no tokenizer dependency for Phase 1
- Implement
domains/base.py with BaseDomain(ABC) abstract base class defining the chunk() method contract
- Create
tests/domains/test_notes.py with unit tests covering: heading splits, code block atomicity, table atomicity, context header format, soft/hard limit enforcement, chunk_hash determinism
Design Guidance
domains/notes.py already exists as a stub — implement NotesDomain there
domains/base.py already exists as a stub — implement BaseDomain(ABC) there
- mistune import pattern:
import mistune; md = mistune.create_markdown(renderer=mistune.AstRenderer())
- The mistune AST renderer produces a list of block dicts. Each dict has a
type key. Known types: heading (with attrs.level 1–6 and children), block_code (with attrs.info for language), table, paragraph, list, block_quote, thematic_break
- Pin mistune to
>=3.0,<4.0 (already in pyproject.toml) — add a regression test that verifies expected AST structure for a known input to catch upstream AST format changes
- Heading breadcrumb algorithm: maintain a stack of heading texts indexed by level. When a heading at level N is encountered, pop all stack entries at levels >= N, push the new heading. The breadcrumb is
>.join of stack entries formatted as #{level} {text}.
- Whitespace normalization before hashing:
re.sub(r'[ \t]+', ' ', text) then '\n'.join(line.rstrip() for line in text.splitlines()) then text.strip()
- Context header format:
"# Root > ## Section > ### Subsection" — the # count reflects the heading level
chunk_type field: use "standard" for normal text chunks, "code" for code block chunks, "table" for table chunks
domain_metadata field: for Phase 1, set to None or include {"heading_level": N} for heading-derived chunks
- Oversized block splitting: when a paragraph block exceeds
hard_limit, split at sentence boundaries (. , ? , ! ) as a fallback — never split a sentence mid-way
- The
chunk() method is the only required method on BaseDomain; its signature: def chunk(self, content: NormalizedContent) -> list[Chunk]
Acceptance Criteria
Dependencies
Phase 1: Data Models and Schema Foundation
Parent Issue
Part of #40
Discussion
This work is detailed in discussion 41
Implement
domains/notes.pywith theNotesDomainclass that splits markdown content into semantically coherent chunks with context headers using mistune for AST-based parsing.Requirements
NotesDomainclass indomains/notes.py__init__(self, soft_limit: int = 512, hard_limit: int = 1024, min_floor: int = 64)chunk(content: NormalizedContent) -> list[Chunk]method:"# Architecture > ## Storage > ### SQLite")min_floor; split oversized sections at paragraph boundaries without exceedinghard_limitchunk_hash = SHA-256(normalized_content)for each chunk — content excludes context header; apply whitespace normalization before hashingcontentfield before constructingChunk(context header is part of the embedded text but excluded from the hash)chunk_indexvaluesChunkinstancesdomains/base.pywithBaseDomain(ABC)abstract base class defining thechunk()method contracttests/domains/test_notes.pywith unit tests covering: heading splits, code block atomicity, table atomicity, context header format, soft/hard limit enforcement,chunk_hashdeterminismDesign Guidance
domains/notes.pyalready exists as a stub — implementNotesDomaintheredomains/base.pyalready exists as a stub — implementBaseDomain(ABC)thereimport mistune; md = mistune.create_markdown(renderer=mistune.AstRenderer())typekey. Known types:heading(withattrs.level1–6 andchildren),block_code(withattrs.infofor language),table,paragraph,list,block_quote,thematic_break>=3.0,<4.0(already in pyproject.toml) — add a regression test that verifies expected AST structure for a known input to catch upstream AST format changes>.join of stack entries formatted as#{level} {text}.re.sub(r'[ \t]+', ' ', text)then'\n'.join(line.rstrip() for line in text.splitlines())thentext.strip()"# Root > ## Section > ### Subsection"— the#count reflects the heading levelchunk_typefield: use"standard"for normal text chunks,"code"for code block chunks,"table"for table chunksdomain_metadatafield: for Phase 1, set toNoneor include{"heading_level": N}for heading-derived chunkshard_limit, split at sentence boundaries (.,?,!) as a fallback — never split a sentence mid-waychunk()method is the only required method onBaseDomain; its signature:def chunk(self, content: NormalizedContent) -> list[Chunk]Acceptance Criteria
BaseDomain(ABC)defines abstractchunk(content: NormalizedContent) -> list[Chunk]NotesDomainimplementsBaseDomain"# H1 text > ## H2 text > ### H3 text"min_floortokens may be joined if combined size is withinhard_limithard_limitis split at paragraph boundarieschunk_hashis identical for two chunks with the same content and different context headerschunk_indexvalues are sequential starting from 0tests/domains/test_notes.pypasses all casesDependencies
Phase 1: Data Models and Schema Foundation
Parent Issue
Part of #40
Discussion
This work is detailed in discussion 41