Skip to content

Commit 324eb67

Browse files
committed
Implement real document parsers and wire them into ingestion (#25)
Replace the NotImplementedError parser stubs with working implementations: - Parsers now return a structured ParsedDocument (text + metadata + heading sections) instead of a bare string, so downstream chunking can be structure-aware. - MarkdownParser strips YAML/TOML frontmatter and extracts heading sections. - HtmlParser extracts clean text, title, and headings via the stdlib HTML parser (no new dependency). - PdfParser extracts per-page text behind the optional ingestion-pdf extra (pypdf), with a clear error when the extra is missing. - JsonParser supports configurable text-field selection, defaulting to the prior deterministic re-serialization. - io/local_files.py now dispatches through a parser registry (the live pipeline previously bypassed the parsers module entirely); adds .html/.htm, .markdown, and .pdf suffixes. Ingestion package coverage 81% -> 87%.
1 parent 359a62b commit 324eb67

7 files changed

Lines changed: 391 additions & 40 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ pip install -e ".[dev]"
6363
| `dev` | `ruff`, `mypy`, `pytest`, `pytest-cov` | Local development |
6464
| `observability-cli` | `jsonschema`, `numpy`, `pandas` | `--validate`, `validate-config`, `--detect-anomalies`, `--export-csv` |
6565
| `parquet` | `pyarrow` | `--export-parquet` (combine with `observability-cli`) |
66+
| `ingestion-pdf` | `pypdf` | Ingesting `.pdf` source documents |
6667

6768
Example: `pip install -e ".[dev,observability-cli,parquet]"`.
6869

@@ -79,7 +80,7 @@ python -m llm_knowledge_ingestion.cli.main # uses configs/ing
7980
python -m llm_knowledge_ingestion.cli.main --config my.yaml # or your own
8081
```
8182

82-
Reads `.txt`, `.md`, `.json` from `ingestion.input_path` and writes `documents.jsonl`, `chunks.jsonl`, `lineage.jsonl`, `index_records.jsonl`. See [docs/mvp-ingestion.md](docs/mvp-ingestion.md).
83+
Reads `.txt`, `.md`/`.markdown`, `.html`/`.htm`, `.json`, and `.pdf` (PDF via the optional `ingestion-pdf` extra) from `ingestion.input_path` and writes `documents.jsonl`, `chunks.jsonl`, `lineage.jsonl`, `index_records.jsonl`. Markdown frontmatter is stripped and heading structure is preserved; HTML is reduced to clean text. See [docs/mvp-ingestion.md](docs/mvp-ingestion.md).
8384

8485
### Dataset foundry
8586

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ observability-cli = [
3232
parquet = [
3333
"pyarrow>=16.0.0",
3434
]
35+
ingestion-pdf = [
36+
"pypdf>=4.0.0",
37+
]
3538
dev = [
3639
"mypy>=1.11.0",
3740
"pytest>=8.3.0",
@@ -40,6 +43,7 @@ dev = [
4043
"types-PyYAML>=6.0",
4144
"types-jsonschema>=4.0.0",
4245
"pandas-stubs>=2.2.0",
46+
"pypdf>=4.0.0",
4347
]
4448

4549
[project.scripts]
Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,31 @@
11
from __future__ import annotations
22

3-
import json
4-
from dataclasses import dataclass
3+
from dataclasses import dataclass, field
54
from pathlib import Path
6-
from typing import Any
75

8-
SUPPORTED_SUFFIXES = {".txt": "text", ".md": "markdown", ".json": "json"}
6+
from llm_knowledge_ingestion.parsers.base import (
7+
DocumentParser,
8+
HtmlParser,
9+
JsonParser,
10+
MarkdownParser,
11+
PdfParser,
12+
Section,
13+
TextParser,
14+
)
15+
16+
# Maps file suffix -> (source_type, parser factory). Adding a format here is the
17+
# single place the live pipeline learns about a new parser.
18+
_PARSER_REGISTRY: dict[str, tuple[str, type[DocumentParser]]] = {
19+
".txt": ("text", TextParser),
20+
".md": ("markdown", MarkdownParser),
21+
".markdown": ("markdown", MarkdownParser),
22+
".html": ("html", HtmlParser),
23+
".htm": ("html", HtmlParser),
24+
".json": ("json", JsonParser),
25+
".pdf": ("pdf", PdfParser),
26+
}
27+
28+
SUPPORTED_SUFFIXES = {suffix: source_type for suffix, (source_type, _) in _PARSER_REGISTRY.items()}
929

1030

1131
@dataclass(frozen=True, slots=True)
@@ -15,41 +35,37 @@ class RawDocument:
1535
content: str
1636
title: str
1737
metadata: dict[str, str]
38+
sections: list[Section] = field(default_factory=list)
1839

1940

20-
def _json_to_content(payload: Any) -> str:
21-
"""Render JSON deterministically so hashes remain stable across runs."""
22-
return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
41+
def parser_for_suffix(suffix: str) -> DocumentParser:
42+
entry = _PARSER_REGISTRY.get(suffix.lower())
43+
if entry is None:
44+
raise ValueError(f"Unsupported file type: {suffix}")
45+
return entry[1]()
2346

2447

2548
def load_local_document(path: Path) -> RawDocument:
2649
suffix = path.suffix.lower()
27-
if suffix not in SUPPORTED_SUFFIXES:
50+
if suffix not in _PARSER_REGISTRY:
2851
raise ValueError(f"Unsupported file type: {path.suffix}")
2952

30-
text = path.read_text(encoding="utf-8")
31-
source_type = SUPPORTED_SUFFIXES[suffix]
32-
title = path.stem
33-
metadata = {"file_name": path.name, "file_suffix": suffix}
53+
source_type = _PARSER_REGISTRY[suffix][0]
54+
parsed = parser_for_suffix(suffix).parse(path.read_bytes())
3455

35-
if source_type == "json":
36-
try:
37-
payload = json.loads(text)
38-
except json.JSONDecodeError as exc:
39-
raise ValueError(f"Invalid JSON file: {path}") from exc
40-
text = _json_to_content(payload)
41-
if isinstance(payload, dict):
42-
for candidate in ("title", "name", "id"):
43-
if candidate in payload and str(payload[candidate]).strip():
44-
title = str(payload[candidate]).strip()
45-
break
56+
title = parsed.metadata.get("title") or path.stem
57+
metadata = {"file_name": path.name, "file_suffix": suffix}
58+
for key, value in parsed.metadata.items():
59+
if key != "title":
60+
metadata[key] = value
4661

4762
return RawDocument(
4863
source_type=source_type,
4964
source_uri=str(path.resolve()),
50-
content=text,
65+
content=parsed.text,
5166
title=title,
5267
metadata=metadata,
68+
sections=parsed.sections,
5369
)
5470

5571

@@ -62,5 +78,5 @@ def discover_input_files(input_path: Path) -> list[Path]:
6278
return sorted(
6379
file
6480
for file in input_path.rglob("*")
65-
if file.is_file() and file.suffix.lower() in SUPPORTED_SUFFIXES
81+
if file.is_file() and file.suffix.lower() in _PARSER_REGISTRY
6682
)
Lines changed: 203 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,225 @@
11
from __future__ import annotations
22

3+
import json
34
from abc import ABC, abstractmethod
5+
from dataclasses import dataclass, field
6+
from html.parser import HTMLParser as _StdHTMLParser
7+
from typing import Any
8+
9+
10+
@dataclass(frozen=True, slots=True)
11+
class Section:
12+
"""A heading boundary discovered by a structure-aware parser.
13+
14+
``start_offset`` is the character index into ``ParsedDocument.text`` where the
15+
section body begins, so heading-aware chunking can split on real boundaries.
16+
"""
17+
18+
heading: str
19+
level: int
20+
start_offset: int
21+
22+
23+
@dataclass(frozen=True, slots=True)
24+
class ParsedDocument:
25+
"""Structured result of parsing a raw payload.
26+
27+
``text`` is the extracted plain text. ``metadata`` carries parser-derived,
28+
string-valued attributes (page counts, document title, heading path).
29+
``sections`` is the heading structure used by heading-aware chunking; it is
30+
empty for formats without headings.
31+
"""
32+
33+
text: str
34+
metadata: dict[str, str] = field(default_factory=dict)
35+
sections: list[Section] = field(default_factory=list)
436

537

638
class DocumentParser(ABC):
739
"""Base parser contract for source document extraction."""
840

941
@abstractmethod
10-
def parse(self, raw_payload: bytes) -> str:
11-
"""Return extracted textual content from raw payload."""
42+
def parse(self, raw_payload: bytes) -> ParsedDocument:
43+
"""Return structured text + metadata extracted from a raw payload."""
1244

1345

14-
class PdfParser(DocumentParser):
15-
def parse(self, raw_payload: bytes) -> str:
16-
raise NotImplementedError("PDF parser not implemented yet")
46+
class MissingParserDependencyError(RuntimeError):
47+
"""Raised when a parser's optional dependency is not installed."""
48+
49+
50+
class TextParser(DocumentParser):
51+
def parse(self, raw_payload: bytes) -> ParsedDocument:
52+
return ParsedDocument(text=raw_payload.decode("utf-8", errors="replace"))
53+
54+
55+
def _strip_frontmatter(text: str) -> tuple[str, dict[str, str]]:
56+
"""Strip a leading YAML (``---``) or TOML (``+++``) frontmatter block.
57+
58+
Frontmatter keys are surfaced as ``frontmatter.<key>`` metadata using a
59+
deliberately small, dependency-free ``key: value`` scan (no nested YAML).
60+
"""
61+
fences = {"---": "---", "+++": "+++"}
62+
lines = text.splitlines(keepends=True)
63+
if not lines:
64+
return text, {}
65+
opener = lines[0].strip()
66+
if opener not in fences:
67+
return text, {}
68+
69+
closer = fences[opener]
70+
meta: dict[str, str] = {}
71+
for index in range(1, len(lines)):
72+
if lines[index].strip() == closer:
73+
for raw_line in lines[1:index]:
74+
if ":" in raw_line:
75+
key, _, value = raw_line.partition(":")
76+
key = key.strip()
77+
value = value.strip().strip("'\"")
78+
if key and value:
79+
meta[f"frontmatter.{key}"] = value
80+
remainder = "".join(lines[index + 1 :])
81+
return remainder.lstrip("\n"), meta
82+
# No closing fence: treat the whole thing as body.
83+
return text, {}
1784

1885

1986
class MarkdownParser(DocumentParser):
20-
def parse(self, raw_payload: bytes) -> str:
21-
raise NotImplementedError("Markdown parser not implemented yet")
87+
def parse(self, raw_payload: bytes) -> ParsedDocument:
88+
body, metadata = _strip_frontmatter(raw_payload.decode("utf-8", errors="replace"))
89+
sections: list[Section] = []
90+
offset = 0
91+
for line in body.splitlines(keepends=True):
92+
stripped = line.lstrip()
93+
if stripped.startswith("#"):
94+
level = len(stripped) - len(stripped.lstrip("#"))
95+
heading = stripped[level:].strip()
96+
if heading and 1 <= level <= 6:
97+
sections.append(Section(heading=heading, level=level, start_offset=offset))
98+
offset += len(line)
99+
if sections and "frontmatter.title" not in metadata:
100+
metadata["title"] = sections[0].heading
101+
return ParsedDocument(text=body, metadata=metadata, sections=sections)
102+
103+
104+
class _HtmlTextExtractor(_StdHTMLParser):
105+
_SKIP = {"script", "style"}
106+
107+
def __init__(self) -> None:
108+
super().__init__(convert_charrefs=True)
109+
self.parts: list[str] = []
110+
self.title_parts: list[str] = []
111+
self.headings: list[str] = []
112+
self._skip_depth = 0
113+
self._in_title = False
114+
self._heading_buffer: list[str] | None = None
115+
116+
def handle_starttag(self, tag: str, attrs: Any) -> None:
117+
if tag in self._SKIP:
118+
self._skip_depth += 1
119+
if tag == "title":
120+
self._in_title = True
121+
if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
122+
self._heading_buffer = []
123+
124+
def handle_endtag(self, tag: str) -> None:
125+
if tag in self._SKIP and self._skip_depth:
126+
self._skip_depth -= 1
127+
if tag == "title":
128+
self._in_title = False
129+
if tag in {"h1", "h2", "h3", "h4", "h5", "h6"} and self._heading_buffer is not None:
130+
heading = "".join(self._heading_buffer).strip()
131+
if heading:
132+
self.headings.append(heading)
133+
self._heading_buffer = None
134+
135+
def handle_data(self, data: str) -> None:
136+
if self._skip_depth:
137+
return
138+
if self._in_title:
139+
self.title_parts.append(data)
140+
if self._heading_buffer is not None:
141+
self._heading_buffer.append(data)
142+
if data.strip():
143+
self.parts.append(data)
22144

23145

24146
class HtmlParser(DocumentParser):
25-
def parse(self, raw_payload: bytes) -> str:
26-
raise NotImplementedError("HTML parser not implemented yet")
147+
"""Dependency-free HTML text extraction via the stdlib HTML parser."""
148+
149+
def parse(self, raw_payload: bytes) -> ParsedDocument:
150+
extractor = _HtmlTextExtractor()
151+
extractor.feed(raw_payload.decode("utf-8", errors="replace"))
152+
extractor.close()
153+
text = " ".join(part.strip() for part in extractor.parts if part.strip())
154+
metadata: dict[str, str] = {}
155+
title = "".join(extractor.title_parts).strip()
156+
if title:
157+
metadata["title"] = title
158+
if extractor.headings:
159+
metadata["headings"] = " > ".join(extractor.headings)
160+
sections = [
161+
Section(heading=heading, level=1, start_offset=0) for heading in extractor.headings
162+
]
163+
return ParsedDocument(text=text, metadata=metadata, sections=sections)
164+
165+
166+
class PdfParser(DocumentParser):
167+
"""PDF text extraction behind the optional ``ingestion-pdf`` extra (pypdf)."""
168+
169+
def parse(self, raw_payload: bytes) -> ParsedDocument:
170+
try:
171+
from pypdf import PdfReader
172+
except ImportError as exc: # pragma: no cover - exercised only without the extra
173+
raise MissingParserDependencyError(
174+
"PDF parsing requires the optional dependency 'pypdf'. "
175+
'Install it with: pip install "llm-observability-analytics[ingestion-pdf]"'
176+
) from exc
177+
178+
import io
179+
180+
reader = PdfReader(io.BytesIO(raw_payload))
181+
pages = [(page.extract_text() or "").strip() for page in reader.pages]
182+
text = "\n\n".join(page for page in pages if page)
183+
metadata = {"page_count": str(len(reader.pages))}
184+
return ParsedDocument(text=text, metadata=metadata)
27185

28186

29187
class JsonParser(DocumentParser):
30-
def parse(self, raw_payload: bytes) -> str:
31-
raise NotImplementedError("JSON parser not implemented yet")
188+
"""Structured JSON extraction.
32189
190+
With ``text_fields`` set, concatenates those top-level string fields (useful
191+
for record-shaped documents). Otherwise re-serializes deterministically so
192+
content hashes stay stable across runs.
193+
"""
33194

34-
class TextParser(DocumentParser):
35-
def parse(self, raw_payload: bytes) -> str:
36-
return raw_payload.decode("utf-8", errors="replace")
195+
def __init__(self, text_fields: list[str] | None = None) -> None:
196+
self.text_fields = text_fields
197+
198+
def parse(self, raw_payload: bytes) -> ParsedDocument:
199+
try:
200+
payload = json.loads(raw_payload.decode("utf-8", errors="replace"))
201+
except json.JSONDecodeError as exc:
202+
raise ValueError(f"Invalid JSON payload: {exc}") from exc
203+
204+
metadata: dict[str, str] = {}
205+
if isinstance(payload, dict):
206+
for candidate in ("title", "name", "id"):
207+
if candidate in payload and str(payload[candidate]).strip():
208+
metadata["title"] = str(payload[candidate]).strip()
209+
break
210+
211+
if self.text_fields and isinstance(payload, dict):
212+
parts = [
213+
str(payload[field_name])
214+
for field_name in self.text_fields
215+
if field_name in payload and str(payload[field_name]).strip()
216+
]
217+
text = "\n".join(parts) if parts else _json_to_content(payload)
218+
else:
219+
text = _json_to_content(payload)
220+
return ParsedDocument(text=text, metadata=metadata)
221+
222+
223+
def _json_to_content(payload: Any) -> str:
224+
"""Render JSON deterministically so hashes remain stable across runs."""
225+
return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))

tests/fixtures/parsers/sample.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>Sample Page</title>
5+
<style>.hidden { display: none; }</style>
6+
<script>console.log("ignore me");</script>
7+
</head>
8+
<body>
9+
<h1>Main Heading</h1>
10+
<p>First paragraph of real content.</p>
11+
<h2>Subsection</h2>
12+
<p>Second paragraph with <a href="https://example.com">a link</a>.</p>
13+
</body>
14+
</html>

0 commit comments

Comments
 (0)