Built-in document parsers for AgentContextOS. Step 1.3 delivers ten format-specific parsers plus a content-type detector that the ingest pipeline (Step 1.10) will wire up after the chunker (Step 1.5) lands.
A Parser consumes raw bytes and returns a ParsedDocument —
- the embedded
Documentwithcontent_hash,tenant_id,corpus_id,mime_type, andstatus=DocumentStatus.readyset - the extracted plain-text
text - a list of structural
Blockrecords (heading / paragraph / list_item / table_row / code / quote / caption / other) with offsets intotext, optional heading level, and parser-specificattributeslike{"page": 3}or{"sheet": "Q1", "row": 5} - the
detected_mime— the parser's normalized MIME if it sniffed past the caller-supplied hint
The downstream chunker (Step 1.5) consumes blocks for structure-aware
boundary detection. Parsers that cannot recover structure (plain text,
opaque blobs) emit a single BlockType.paragraph spanning the full
extracted text.
| MIME | Parser | Library | Heading detection |
|---|---|---|---|
text/plain and other text/* |
PlainTextParser |
stdlib | none — blank-line paragraph split |
text/markdown |
MarkdownParser |
markdown-it-py |
#–###### → level 1–6 |
text/html, application/xhtml+xml |
HtmlParser |
beautifulsoup4 + stdlib html.parser |
<h1>–<h6> |
application/json, application/x-ndjson |
JsonParser |
stdlib | none — re-emitted as indented text |
text/csv, text/tab-separated-values |
CsvParser |
stdlib | first row treated as header (attributes.header=True) |
application/yaml, text/yaml, text/x-yaml, application/x-yaml |
YamlParser |
PyYAML (safe_load) |
none |
application/pdf |
PdfParser |
pypdf |
none — one paragraph block per page |
application/vnd.openxmlformats-officedocument.wordprocessingml.document |
DocxParser |
python-docx |
Heading 1–Heading 6 styles |
application/vnd.openxmlformats-officedocument.presentationml.presentation |
PptxParser |
python-pptx |
slide title → level-2 heading |
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
XlsxParser |
openpyxl (read-only) |
sheet name → level-2 heading |
All libraries are pure Python (or distributed as wheels), so the package installs identically on Windows, macOS, and Linux without WSL.
from rag_core.types import (
CorpusId, DocumentId, Principal, PrincipalId, PrincipalKind,
RequestContext, TenantId,
)
from rag_parsers import default_registry, detect_mime
tenant = TenantId("acme")
ctx = RequestContext(
tenant_id=tenant,
principal=Principal(
id=PrincipalId("svc"),
kind=PrincipalKind.service,
display_name="svc",
tenant_id=tenant,
),
)
with open("memo.docx", "rb") as f:
data = f.read()
mime = detect_mime(data, filename="memo.docx")
parser = default_registry().select(mime)
parsed = await parser.parse(
ctx, data, mime,
DocumentId("d1"), CorpusId("c1"), "file:///memo.docx",
)
print(parsed.text[:200])
for block in parsed.blocks[:5]:
print(block.type, block.level, block.text[:60])detect_mime(data=..., filename=..., hint=...) accepts any combination
of bytes / filename / caller hint. Resolution order:
- Extension — if
filenameis given, the lowercase extension wins for known formats. Office and PDF extensions are deterministic, so no content sniffing is needed. - Content sniffing via
puremagicon the first 4 KiB. Whenpuremagicreturns the genericapplication/zipfor an OOXML file, the ZIP central directory is inspected to distinguishdocx/pptx/xlsx. - Caller hint (e.g. an HTTP
Content-Typeheader) as a last-resort fallback. application/octet-streamif all three fail.
default_registry() returns a ParserRegistry pre-populated with every
built-in parser in the order specific → generic: Markdown, HTML, JSON,
CSV, YAML, PDF, DOCX, PPTX, XLSX, PlainText. Use registry.select(mime)
to resolve a parser; it raises ParseError when no registered parser
accepts the MIME type.
You can also build a custom registry to override default ordering or to
inject your own Parser implementation:
from rag_parsers import ParserRegistry, PlainTextParser
registry = ParserRegistry()
registry.register(MyHtmlParser()) # takes precedence over the default
registry.register(PlainTextParser())A smoke-test command ships with the CLI:
$ ragctl parse memo.docx
file: memo.docx
mime: application/vnd.openxmlformats-officedocument.wordprocessingml.document
parser: DocxParser
bytes: 24,816
sha256: a1b2c3...
text: 1,847 chars
blocks: 18 (heading=3, paragraph=12, table_row=3)
first blocks:
[heading h1] @0: Section One
[paragraph] @13: First body paragraph.
...
Useful for design-partner demos and triaging parse problems before the full ingest pipeline lands in Step 1.10.
ParseErroris raised when input bytes are malformed (corrupt PDF, invalid JSON, broken OOXML, undecodable bytes).- Empty input returns an empty
ParsedDocument—text="",blocks=[],document.content_hashstill set (sha256 ofb""). Callers should decide whether to skip empty documents downstream rather than have parsers raise. - Encrypted PDFs are rejected with a
ParseError— password-protected ingestion is deferred to Step 6.7 (BYOK / secrets).
BlockType is deliberately small:
heading— carrieslevel1–6paragraph— generic flowing textlist_item— bullet / numbered list elementtable_row— one row of a table (HTML<tr>, DOCX table, CSV/TSV row, XLSX row)code— preformatted code blockquote—<blockquote>/ Markdown>quotecaption— figure caption (HTML<figcaption>) or PPTX speaker notesother— escape hatch for parser-specific fragments
Parsers normalize text inside blocks (collapsed whitespace, stripped leading/trailing spaces) but do not merge across structural boundaries — the chunker owns higher-level grouping decisions.
Block start offsets index into ParsedDocument.text, the same string
that was used to compute document.content_hash-adjacent text length
metrics. Parsers guarantee:
text[block.start : block.start + len(block.text)] == block.textfor plain-text / Markdown / HTML, where reading order matches input order.- For tabular formats (CSV / DOCX tables / XLSX) and OOXML where text is
re-assembled by the parser, offsets index into the re-assembled
text, not the source bytes. The chunker should treatstartas an intra-document anchor, not a byte offset.
Parsers are designed to be cheap to construct (registry-friendly) and to
do real work inside parse(). Tests on the developer laptop (M1):
- text-family: < 5 ms per file
- PDF (single-page): ~30 ms
- DOCX / PPTX / XLSX: ~50–150 ms
- 10-page PDF: ~200 ms
Step 1.5's chunker is expected to dominate; Step 1.10's pipeline will
wrap parsers in a Batcher so connector-triggered concurrent fetches
coalesce into batched parses.
Adding a new parser:
- Implement
rag_core.spi.Parserreturning aParsedDocument. - Use
rag_parsers._base.build_documentto assemble the embeddedDocumentwithcontent_hashset correctly. - Register via
ParserRegistry.register(MyParser())— first match wins. - Add the new MIME to
rag_parsers.mime._EXT_MIMEif a file-extension shortcut is appropriate. - Add a conformance test under
tests/parsers/and updatedocs/reference/parsers.md.
See docs/architecture/parsers.md for the design rationale and the policy / pipeline integration plan.