Skip to content

Commit b234d83

Browse files
committed
Add pluggable ingest bundle pipeline
1 parent 5a2b48c commit b234d83

33 files changed

Lines changed: 3470 additions & 17 deletions

examples/ingest-plugin/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# OpenKB Ingest Plugin Example
2+
3+
This directory shows the minimal shape of an external package that contributes
4+
bundle ingest components through Python entry points.
5+
6+
Install a package like this in the same environment as OpenKB, then enable its
7+
components in `.openkb/config.yaml`:
8+
9+
```yaml
10+
ingest:
11+
pipeline: bundle
12+
importers:
13+
enabled:
14+
- example_text
15+
normalizers:
16+
enabled:
17+
- example_text
18+
```
19+
20+
The entry point groups are:
21+
22+
- `openkb.ingest.importers`
23+
- `openkb.ingest.normalizers`
24+
- `openkb.ingest.enrichers`
25+
26+
Each entry point should resolve to a class or factory returning an object that
27+
matches the corresponding OpenKB ingest protocol.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from __future__ import annotations
2+
3+
from openkb.ingest.models import DocumentBundle, IngestInput, ProvenanceRecord, TextBlock
4+
5+
6+
class ExampleTextImporter:
7+
name = "example_text"
8+
9+
def can_handle(self, target: str, context) -> bool:
10+
del context
11+
return target.startswith("example-text:")
12+
13+
def import_source(self, target: str, context) -> IngestInput:
14+
path = context.staging_dir / "raw" / "example.txt"
15+
path.parent.mkdir(parents=True, exist_ok=True)
16+
path.write_text(target.removeprefix("example-text:").strip() + "\n", encoding="utf-8")
17+
return IngestInput(
18+
target=target,
19+
path=path,
20+
source_uri=target,
21+
media_type="text/x-example",
22+
metadata={"display_name": "example.txt"},
23+
)
24+
25+
26+
class ExampleTextNormalizer:
27+
name = "example_text"
28+
29+
def supports(self, input_: IngestInput, context) -> bool:
30+
del context
31+
return input_.media_type == "text/x-example"
32+
33+
def normalize(self, input_: IngestInput, context) -> DocumentBundle:
34+
del context
35+
if input_.path is None:
36+
raise ValueError("Example normalizer requires a local path.")
37+
source_uri = input_.source_uri or input_.target
38+
text = input_.path.read_text(encoding="utf-8")
39+
return DocumentBundle(
40+
id=source_uri,
41+
title="Example Text",
42+
source_uri=source_uri,
43+
blocks=[TextBlock(text)],
44+
metadata={
45+
"display_name": input_.metadata.get("display_name", input_.path.name),
46+
"source_path": input_.path.as_posix(),
47+
"source_identity": source_uri,
48+
},
49+
provenance=[ProvenanceRecord(source_uri=source_uri)],
50+
)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[project]
2+
name = "openkb-example-ingest-plugin"
3+
version = "0.1.0"
4+
requires-python = ">=3.10"
5+
dependencies = ["openkb"]
6+
7+
[project.entry-points."openkb.ingest.importers"]
8+
example_text = "openkb_example_ingest:ExampleTextImporter"
9+
10+
[project.entry-points."openkb.ingest.normalizers"]
11+
example_text = "openkb_example_ingest:ExampleTextNormalizer"

openkb/cli.py

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,17 @@ def _setup_llm_key(kb_dir: Path | None = None) -> None:
240240
".csv",
241241
}
242242

243+
BUNDLE_ONLY_EXTENSIONS = {
244+
".png",
245+
".jpg",
246+
".jpeg",
247+
".gif",
248+
".webp",
249+
".bmp",
250+
}
251+
252+
BUNDLE_SUPPORTED_EXTENSIONS = SUPPORTED_EXTENSIONS | BUNDLE_ONLY_EXTENSIONS
253+
243254
# Map raw doc types to display types
244255
_TYPE_DISPLAY_MAP = {
245256
"long_pdf": "pageindex",
@@ -987,9 +998,16 @@ def init(model, language):
987998
help="Import an already-indexed PageIndex Cloud document by its doc-id "
988999
"(no local file). Mutually exclusive with PATH.",
9891000
)
1001+
@click.option(
1002+
"--ingest-pipeline",
1003+
"ingest_pipeline",
1004+
type=click.Choice(["legacy", "bundle"]),
1005+
default=None,
1006+
help="Select the add ingest pipeline. Defaults to .openkb/config.yaml ingest.pipeline, then legacy.",
1007+
)
9901008
@click.pass_context
9911009
@_with_kb_lock(exclusive=True)
992-
def add(ctx, path, from_pageindex_cloud):
1010+
def add(ctx, path, from_pageindex_cloud, ingest_pipeline):
9931011
"""Add a document or directory of documents at PATH to the knowledge base.
9941012
9951013
PATH may be a local file, a local directory (which is walked
@@ -1021,13 +1039,23 @@ def add(ctx, path, from_pageindex_cloud):
10211039
click.echo("Provide a PATH or use --from-pageindex-cloud <DOC_ID>.")
10221040
return
10231041

1024-
# URL ingest: download into raw/ first, then call add_single_file explicitly.
1025-
# Keep staged conversion enabled so converted source artifacts do not touch
1026-
# the live KB before the mutation snapshot exists. The tri-state outcome
1027-
# still lets us clean up the just-downloaded raw file on dedup.
1042+
from openkb.ingest.add import add_bundle_target, resolve_ingest_pipeline
1043+
1044+
config = load_config(kb_dir / ".openkb" / "config.yaml")
1045+
pipeline = resolve_ingest_pipeline(config, ingest_pipeline)
1046+
supported_extensions = (
1047+
BUNDLE_SUPPORTED_EXTENSIONS if pipeline == "bundle" else SUPPORTED_EXTENSIONS
1048+
)
1049+
1050+
# Legacy URL ingest downloads into raw/ first, then calls add_single_file.
1051+
# Bundle URL ingest keeps downloads inside the bundle staging directory and
1052+
# commits through the same mutation boundary as local bundle files.
10281053
from openkb.url_ingest import looks_like_url, fetch_url_to_raw
10291054

10301055
if looks_like_url(path):
1056+
if pipeline == "bundle":
1057+
add_bundle_target(path, kb_dir, legacy_fallback=add_single_file)
1058+
return
10311059
fetched = fetch_url_to_raw(path, kb_dir)
10321060
if fetched is None:
10331061
return
@@ -1049,7 +1077,7 @@ def add(ctx, path, from_pageindex_cloud):
10491077
files = [
10501078
f
10511079
for f in sorted(target.rglob("*"))
1052-
if f.is_file() and f.suffix.lower() in SUPPORTED_EXTENSIONS
1080+
if f.is_file() and f.suffix.lower() in supported_extensions
10531081
]
10541082
if not files:
10551083
click.echo(f"No supported files found in {path}.")
@@ -1058,15 +1086,21 @@ def add(ctx, path, from_pageindex_cloud):
10581086
click.echo(f"Found {total} supported file(s) in {path}.")
10591087
for i, f in enumerate(files, 1):
10601088
click.echo(f"\n[{i}/{total}] ", nl=False)
1061-
add_single_file(f, kb_dir)
1089+
if pipeline == "bundle":
1090+
add_bundle_target(f, kb_dir, legacy_fallback=add_single_file)
1091+
else:
1092+
add_single_file(f, kb_dir)
10621093
else:
1063-
if target.suffix.lower() not in SUPPORTED_EXTENSIONS:
1094+
if target.suffix.lower() not in supported_extensions:
10641095
click.echo(
10651096
f"Unsupported file type: {target.suffix}. "
1066-
f"Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
1097+
f"Supported: {', '.join(sorted(supported_extensions))}"
10671098
)
10681099
return
1069-
add_single_file(target, kb_dir)
1100+
if pipeline == "bundle":
1101+
add_bundle_target(target, kb_dir, legacy_fallback=add_single_file)
1102+
else:
1103+
add_single_file(target, kb_dir)
10701104

10711105

10721106
def _stream_to_tty() -> bool:
@@ -1309,6 +1343,13 @@ def remove(ctx, identifier, keep_raw, keep_empty, dry_run, yes):
13091343
)
13101344
)
13111345

1346+
bundle_path = None
1347+
if meta.get("bundle_path"):
1348+
candidate = kb_dir / meta["bundle_path"]
1349+
if candidate.exists():
1350+
bundle_path = candidate
1351+
actions.append(("DELETE", str(candidate.relative_to(kb_dir))))
1352+
13121353
# Scan concept pages to predict which will be edited vs. deleted.
13131354
# Only frontmatter ``sources:`` membership drives the plan — body-only
13141355
# references (e.g. a stray ``See also:`` line a user added by hand
@@ -1424,6 +1465,8 @@ def remove(ctx, identifier, keep_raw, keep_empty, dry_run, yes):
14241465
source_json.unlink(missing_ok=True)
14251466
if images_dir.is_dir():
14261467
shutil.rmtree(images_dir, ignore_errors=True)
1468+
if bundle_path is not None:
1469+
bundle_path.unlink(missing_ok=True)
14271470

14281471
concept_result = remove_doc_from_concept_pages(
14291472
wiki_dir,

openkb/ingest/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Pluggable ingest pipeline for OpenKB."""
2+
3+
from __future__ import annotations
4+
5+
from openkb.ingest.add import add_bundle_target, resolve_ingest_pipeline
6+
7+
__all__ = ["add_bundle_target", "resolve_ingest_pipeline"]

0 commit comments

Comments
 (0)