Skip to content

Commit 208c307

Browse files
joshuafontanyclaude
andcommitted
feat: ndjson source adapter — ingest a JSON-Lines spool of pre-extracted records
A generic first-party source adapter (RFC 002 §3.2): each NDJSON line is one pre-extracted record {content, source_file, metadata, chunk_index} filed as one verbatim drawer. Byte-preserving (no declared transformations), per-source_file chunk-index so records sharing a source_file never collide on the deterministic drawer id, optional --wing routing fallback. Registered first-party in sources/__init__.py (not the third-party entry-point group). 11 conformance tests. Carries zero caller-specific vocabulary — any pipeline that pre-extracts records can mine --source ndjson <spool>. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9a87f49 commit 208c307

3 files changed

Lines changed: 294 additions & 0 deletions

File tree

mempalace/sources/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
TransformationViolationError,
3636
)
3737
from .context import PalaceContext, ProgressHook
38+
from .ndjson import NdjsonSourceAdapter
3839
from .registry import (
3940
available_adapters,
4041
get_adapter,
@@ -45,6 +46,11 @@
4546
unregister,
4647
)
4748

49+
# First-party in-tree adapters register on import (RFC 002 §3.2 — explicit
50+
# registration, not the third-party entry-point group). This makes
51+
# ``mine --source ndjson`` resolve without an install step.
52+
register(NdjsonSourceAdapter.name, NdjsonSourceAdapter)
53+
4854
__all__ = [
4955
"AdapterClosedError",
5056
"AdapterSchema",
@@ -54,6 +60,7 @@
5460
"FieldSpec",
5561
"IngestMode",
5662
"IngestResult",
63+
"NdjsonSourceAdapter",
5764
"PalaceContext",
5865
"ProgressHook",
5966
"RouteHint",

mempalace/sources/ndjson.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""NDJSON source adapter (RFC 002) — ingest a JSON-Lines spool of pre-extracted records.
2+
3+
For any pipeline that has already extracted and chunked content elsewhere and wants
4+
to hand MemPalace ready-to-file records: each line of the spool is one record, filed
5+
as one drawer. The adapter is byte-preserving — it stores ``content`` verbatim and
6+
declares no transformations (the foundational promise; see ``CLAUDE.md`` "Verbatim
7+
always"). It carries no opinion about *where* the records came from.
8+
9+
One NDJSON line is one record::
10+
11+
{"content": "...", "source_file": "...", "metadata": {...}, "chunk_index": 0}
12+
13+
``content`` and ``source_file`` are required strings; ``metadata`` (flat scalars) and
14+
``chunk_index`` (int) are optional. ``metadata`` passes through verbatim — producers
15+
may attach any flat-scalar fields, opaque to this adapter. When ``chunk_index`` is
16+
absent the adapter assigns a running per-``source_file`` ordinal so two records that
17+
share a ``source_file`` never collide on the deterministic drawer id
18+
(``sha256(source_file)_chunk``).
19+
20+
``SourceRef.local_path`` points at the spool file. The optional ``--wing`` routing
21+
flag (``SourceRef.options['wing']``) fills the ``wing`` metadata key only where a
22+
record left it unset (RFC 002 §2.5 — the record's own routing wins).
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import json
28+
import os
29+
from typing import Iterator
30+
31+
from .base import (
32+
AdapterSchema,
33+
BaseSourceAdapter,
34+
DrawerRecord,
35+
IngestResult,
36+
SourceAdapterError,
37+
SourceNotFoundError,
38+
SourceRef,
39+
)
40+
41+
42+
class NdjsonSourceAdapter(BaseSourceAdapter):
43+
"""File a newline-delimited JSON spool of pre-extracted records into verbatim drawers.
44+
45+
First-party, in-tree — registered manually (RFC 002 §3.2), not via the
46+
third-party entry-point group.
47+
"""
48+
49+
name = "ndjson"
50+
adapter_version = "0.1.0"
51+
capabilities = frozenset({"byte_preserving"})
52+
supported_modes = frozenset({"whole_record"})
53+
declared_transformations = frozenset() # verbatim — the producer pre-chunked
54+
default_privacy_class = "pii_potential"
55+
56+
def ingest(
57+
self,
58+
*,
59+
source: SourceRef,
60+
palace: "object", # PalaceContext — broad to avoid the import cycle
61+
) -> Iterator[IngestResult]:
62+
path = source.local_path
63+
if not path or not os.path.isfile(path):
64+
raise SourceNotFoundError(
65+
f"ndjson: spool file not found: {path!r} (expected a newline-delimited JSON file)"
66+
)
67+
68+
# Honor the --wing routing precedence (§2.5): a record's own wing wins;
69+
# the flag fills in only where the producer left routing unannotated.
70+
fallback_wing = source.options.get("wing") if source.options else None
71+
72+
# Per-source_file ordinal so absent chunk_index values never collide on
73+
# the deterministic drawer id. Provided chunk_index values pass through.
74+
ordinals: dict[str, int] = {}
75+
76+
with open(path, encoding="utf-8") as fh:
77+
for line_no, raw in enumerate(fh, start=1):
78+
line = raw.strip()
79+
if not line:
80+
continue
81+
try:
82+
record = json.loads(line)
83+
except json.JSONDecodeError as exc:
84+
raise SourceAdapterError(
85+
f"ndjson: malformed JSON at {path}:{line_no}: {exc}"
86+
) from exc
87+
if not isinstance(record, dict):
88+
raise SourceAdapterError(f"ndjson: line {path}:{line_no} is not a JSON object")
89+
90+
content = record.get("content")
91+
source_file = record.get("source_file")
92+
if not isinstance(content, str) or not isinstance(source_file, str):
93+
raise SourceAdapterError(
94+
f"ndjson: line {path}:{line_no} lacks string 'content' and 'source_file'"
95+
)
96+
97+
metadata = dict(record.get("metadata") or {})
98+
if fallback_wing and "wing" not in metadata:
99+
metadata["wing"] = fallback_wing
100+
101+
provided = record.get("chunk_index")
102+
if isinstance(provided, int):
103+
chunk_index = provided
104+
else:
105+
chunk_index = ordinals.get(source_file, 0)
106+
ordinals[source_file] = chunk_index + 1
107+
108+
yield DrawerRecord(
109+
content=content,
110+
source_file=source_file,
111+
chunk_index=chunk_index,
112+
metadata=metadata,
113+
)
114+
115+
def describe_schema(self) -> AdapterSchema:
116+
# No fixed structured schema: metadata is producer-defined and flows
117+
# through verbatim (upsert_drawer does not reject undeclared fields).
118+
return AdapterSchema(version="1.0", fields={})
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""Tests for the first-party ``ndjson`` source adapter (RFC 002).
2+
3+
A pipeline that pre-extracts records writes a JSON-Lines spool and runs
4+
``mempalace mine --source ndjson <spool>``; this adapter reads it back into
5+
verbatim drawers.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
import os
12+
from argparse import Namespace
13+
14+
import pytest
15+
16+
from mempalace.sources import available_adapters
17+
from mempalace.sources.base import SourceAdapterError, SourceNotFoundError, SourceRef
18+
from mempalace.sources.ndjson import NdjsonSourceAdapter
19+
20+
21+
def _write_spool(tmp_dir, records) -> str:
22+
path = os.path.join(tmp_dir, "batch-0.ndjson")
23+
with open(path, "w", encoding="utf-8") as fh:
24+
fh.write("\n".join(json.dumps(r) for r in records) + "\n")
25+
return path
26+
27+
28+
def test_ndjson_is_registered_first_party():
29+
# Registered on import of mempalace.sources — no install step needed.
30+
assert "ndjson" in available_adapters()
31+
32+
33+
def test_ingest_reads_spool_verbatim(tmp_dir):
34+
spool = _write_spool(
35+
tmp_dir,
36+
[
37+
{
38+
"content": "the record stored exactly as written",
39+
"source_file": "feed://abc/item/1",
40+
"metadata": {"opaque_field": "kept", "wing": "wing_a"},
41+
}
42+
],
43+
)
44+
adapter = NdjsonSourceAdapter()
45+
out = list(adapter.ingest(source=SourceRef(local_path=spool), palace=None))
46+
47+
assert len(out) == 1
48+
drawer = out[0]
49+
assert drawer.content == "the record stored exactly as written" # verbatim
50+
assert drawer.source_file == "feed://abc/item/1"
51+
assert drawer.chunk_index == 0
52+
assert drawer.metadata["opaque_field"] == "kept" # producer metadata flows through
53+
assert drawer.metadata["wing"] == "wing_a"
54+
55+
56+
def test_absent_chunk_index_gets_per_source_ordinal(tmp_dir):
57+
# Two records sharing a source_file with no chunk_index must NOT collide on
58+
# the deterministic drawer id (sha256(source_file)_chunk).
59+
spool = _write_spool(
60+
tmp_dir,
61+
[
62+
{"content": "first", "source_file": "feed://x"},
63+
{"content": "second", "source_file": "feed://x"},
64+
{"content": "other", "source_file": "feed://y"},
65+
],
66+
)
67+
out = list(NdjsonSourceAdapter().ingest(source=SourceRef(local_path=spool), palace=None))
68+
69+
triples = [(d.source_file, d.chunk_index, d.content) for d in out]
70+
assert triples == [
71+
("feed://x", 0, "first"),
72+
("feed://x", 1, "second"),
73+
("feed://y", 0, "other"),
74+
]
75+
76+
77+
def test_provided_chunk_index_passes_through(tmp_dir):
78+
spool = _write_spool(
79+
tmp_dir,
80+
[{"content": "c", "source_file": "feed://x", "chunk_index": 7}],
81+
)
82+
out = list(NdjsonSourceAdapter().ingest(source=SourceRef(local_path=spool), palace=None))
83+
assert out[0].chunk_index == 7
84+
85+
86+
def test_wing_option_fills_only_when_absent(tmp_dir):
87+
spool = _write_spool(
88+
tmp_dir,
89+
[
90+
{"content": "a", "source_file": "s://1"},
91+
{"content": "b", "source_file": "s://2", "metadata": {"wing": "wing_own"}},
92+
],
93+
)
94+
out = list(
95+
NdjsonSourceAdapter().ingest(
96+
source=SourceRef(local_path=spool, options={"wing": "wing_flag"}), palace=None
97+
)
98+
)
99+
assert out[0].metadata["wing"] == "wing_flag" # filled from the flag
100+
assert out[1].metadata["wing"] == "wing_own" # the record's own wing wins
101+
102+
103+
def test_blank_lines_skipped(tmp_dir):
104+
path = os.path.join(tmp_dir, "batch.ndjson")
105+
with open(path, "w", encoding="utf-8") as fh:
106+
fh.write('\n{"content": "c", "source_file": "s"}\n\n')
107+
out = list(NdjsonSourceAdapter().ingest(source=SourceRef(local_path=path), palace=None))
108+
assert len(out) == 1
109+
110+
111+
def test_malformed_json_raises(tmp_dir):
112+
path = os.path.join(tmp_dir, "bad.ndjson")
113+
with open(path, "w", encoding="utf-8") as fh:
114+
fh.write("{not json}\n")
115+
with pytest.raises(SourceAdapterError):
116+
list(NdjsonSourceAdapter().ingest(source=SourceRef(local_path=path), palace=None))
117+
118+
119+
def test_missing_required_fields_raises(tmp_dir):
120+
spool = _write_spool(tmp_dir, [{"content": "no source_file"}])
121+
with pytest.raises(SourceAdapterError):
122+
list(NdjsonSourceAdapter().ingest(source=SourceRef(local_path=spool), palace=None))
123+
124+
125+
def test_missing_spool_raises_not_found(tmp_dir):
126+
with pytest.raises(SourceNotFoundError):
127+
list(
128+
NdjsonSourceAdapter().ingest(
129+
source=SourceRef(local_path=os.path.join(tmp_dir, "nope.ndjson")), palace=None
130+
)
131+
)
132+
133+
134+
def test_end_to_end_mine_source_ndjson_files_drawers(tmp_dir, palace_path):
135+
from mempalace.cli import _mine_via_source_adapter
136+
from mempalace.palace import get_collection
137+
138+
spool = _write_spool(
139+
tmp_dir,
140+
[
141+
{
142+
"content": "pre-extracted record line",
143+
"source_file": "feed://e2e/1",
144+
"metadata": {"opaque_field": "kept"},
145+
}
146+
],
147+
)
148+
args = Namespace(source="ndjson", dir=spool, wing=None, dry_run=False)
149+
_mine_via_source_adapter(args, palace_path)
150+
151+
col = get_collection(palace_path, create=True)
152+
got = col.get(where={"source_file": "feed://e2e/1"})
153+
assert got["documents"] == ["pre-extracted record line"]
154+
meta = got["metadatas"][0]
155+
assert meta["opaque_field"] == "kept"
156+
assert meta["adapter_name"] == "ndjson" # stamped by PalaceContext, not by the adapter
157+
assert meta["adapter_version"] == "0.1.0"
158+
159+
160+
def test_end_to_end_dry_run_files_nothing(tmp_dir, palace_path):
161+
from mempalace.cli import _mine_via_source_adapter
162+
from mempalace.palace import get_collection
163+
164+
spool = _write_spool(tmp_dir, [{"content": "c", "source_file": "feed://dry/1"}])
165+
args = Namespace(source="ndjson", dir=spool, wing=None, dry_run=True)
166+
_mine_via_source_adapter(args, palace_path)
167+
168+
col = get_collection(palace_path, create=True)
169+
assert col.get(where={"source_file": "feed://dry/1"})["ids"] == []

0 commit comments

Comments
 (0)