Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions examples/configuration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,6 @@ Everything that controls how OpenKB talks to your LLM lives in two places:
pip install openkb
```

OpenKB pins a **pre-release** of its PageIndex dependency
(`pageindex==0.3.0.dev3`), which some installers skip by default. If an install
can't resolve `pageindex`, allow pre-releases:

```bash
uv tool install openkb --prerelease=allow # uv
pip install --pre openkb # pip
```

If `openkb` isn't found *after* a successful install, the console-script directory
isn't on your `PATH` (e.g. `pip --user` installs to `~/.local/bin`) — add it to
`PATH`.
Expand Down
6 changes: 3 additions & 3 deletions examples/pageindex-cloud/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,10 @@ import os
from pageindex import PageIndexClient

client = PageIndexClient(api_key=os.environ["PAGEINDEX_API_KEY"])
col = client.collection()

for doc in col.list_documents():
print(doc["doc_id"], "—", doc.get("doc_name"))
result = client.list_documents()
for doc in result.get("entries", result.get("documents", [])):
print(doc.get("id", doc.get("doc_id")), "—", doc.get("name", doc.get("doc_name")))
```

```text
Expand Down
11 changes: 6 additions & 5 deletions openkb/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ def commit_body(snapshot) -> None:
click.echo(" Long document detected — indexing with PageIndex...")
# PageIndex content-dedups: if the same content is already indexed
# (e.g. hashes.json and pageindex.db diverged after a remove whose
# PageIndex cleanup failed), col.add() returns the EXISTING doc_id
# PageIndex cleanup failed), submit_document() returns the EXISTING doc_id
# and writes no new blob. Capture the blob set *before* indexing so
# we register only blobs THIS add actually created — otherwise
# rollback would delete a prior document's blob.
Expand Down Expand Up @@ -1297,20 +1297,21 @@ def _cleanup_pageindex(
config = resolve_effective_config(kb_dir)[0]
model = config.get("model", DEFAULT_CONFIG.get("model", "gpt-5.4"))
client = PageIndexClient(model=model, storage_path=str(openkb_dir))
col = client.collection()

if doc_id is None:
candidates = [d for d in col.list_documents() if d.get("doc_name") == doc_name]
result = client.list_documents()
entries = result.get("entries", result.get("documents", []))
candidates = [d for d in entries if d.get("name") == doc_name]
if not candidates:
return False, "no PageIndex doc to delete"
if len(candidates) > 1:
return False, (
f"{len(candidates)} PageIndex docs match doc_name='{doc_name}'; "
"skipping (re-add to refresh)"
)
doc_id = candidates[0]["doc_id"]
doc_id = candidates[0].get("id", candidates[0].get("doc_id"))

col.delete_document(doc_id)
client.delete_document(doc_id)
return True, f"deleted PageIndex doc ({doc_id[:12]}…)"


Expand Down
92 changes: 39 additions & 53 deletions openkb/indexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
import os
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Any
from typing import Any, cast

from pageindex import IndexConfig, PageIndexClient
from pageindex import PageIndexClient
from pageindex.types import LocalIndexConfig

from openkb.config import resolve_concurrency, resolve_effective_config
from openkb.tree_renderer import render_summary_md
Expand Down Expand Up @@ -153,31 +154,21 @@ def _write_long_doc_artifacts(
return summary_path


def _build_index_config(config: dict[str, Any]) -> IndexConfig:
"""Build the PageIndex ``IndexConfig`` for local indexing.
def _build_index_config(config: dict[str, Any]) -> LocalIndexConfig:
"""Build the PageIndex ``LocalIndexConfig`` for local indexing.

Forwards the KB's ``concurrency`` setting to PageIndex, which caps how many
indexing LLM calls run at once (guarding against "too many open files" fd
exhaustion on large documents). The value is only passed when set *and* the
installed PageIndex's ``IndexConfig`` declares the field, so OpenKB keeps
working against a pinned PageIndex that predates it (``IndexConfig``
forbids unknown kwargs).
``pageindex>=0.2.19`` exposes ``IndexConfig`` as a
``Union[LocalIndexConfig, CloudIndexConfig]`` TypedDict alias — not an
instantiable class. Return a ``LocalIndexConfig`` dict instead.
"""
kwargs: dict[str, Any] = {
"if_add_node_text": True,
"if_add_node_summary": True,
"if_add_doc_description": True,
}
index_config: LocalIndexConfig = {}
concurrency = resolve_concurrency(config)
if concurrency is not None:
if "max_concurrency" in IndexConfig.model_fields:
kwargs["max_concurrency"] = concurrency
else:
logger.warning(
"config: 'concurrency' is set but the installed PageIndex "
"version does not support it yet — ignoring it."
)
return IndexConfig(**kwargs)
logger.warning(
"config: 'concurrency' is set but the installed PageIndex "
"version does not support it yet — ignoring it."
)
return index_config


def index_long_document(pdf_path: Path, kb_dir: Path, doc_name: str | None = None) -> IndexResult:
Expand All @@ -199,16 +190,16 @@ def index_long_document(pdf_path: Path, kb_dir: Path, doc_name: str | None = Non
api_key=pageindex_api_key or None,
model=model,
storage_path=str(openkb_dir),
index_config=index_config,
index=index_config,
)
col = client.collection()

# Add PDF (retry up to 3 times — PageIndex TOC accuracy is stochastic)
# Submit PDF (retry up to 3 times — PageIndex TOC accuracy is stochastic)
max_retries = 3
doc_id = None
for attempt in range(1, max_retries + 1):
try:
doc_id = col.add(str(pdf_path))
result = client.submit_document(str(pdf_path))
doc_id = cast(str, result["doc_id"])
logger.info(
"PageIndex added %s → doc_id=%s (attempt %d)", pdf_path.name, doc_id, attempt
)
Expand All @@ -226,22 +217,18 @@ def index_long_document(pdf_path: Path, kb_dir: Path, doc_name: str | None = Non
f"Failed to index {pdf_path.name} after {max_retries} attempts: {exc}"
) from exc

# The PageIndex blob for doc_id is now durably on disk. The add mutation no
# longer eagerly snapshots .openkb/files — it registers the new blob via
# snapshot.track_new() only on a successful return — so if any step below
# fails, delete the document we just added. Otherwise the blob leaks as an
# orphan that pageindex.db (rolled back by the snapshot) no longer refs and
# no reaper reclaims.
# The PageIndex blob for doc_id is now durably on disk. If any step below
# fails, delete the document we just added.
try:
# Fetch complete document (metadata + structure + text)
doc = col.get_document(doc_id, include_text=True)
indexed_doc_name: str = doc.get("doc_name", pdf_path.stem)
description: str = doc.get("doc_description", "")
structure: list = doc.get("structure", [])
doc = client.get_document(doc_id)
indexed_doc_name: str = doc.get("name", pdf_path.stem)
description: str = doc.get("description", "")
page_tree = client.get_tree(doc_id, include_text=True)
structure: list = page_tree.get("result", [])

# Debug: print doc keys and page_count to diagnose get_page_content range
logger.info("Doc keys: %s", list(doc.keys()))
logger.info("page_count from doc: %s", doc.get("page_count", "NOT PRESENT"))
logger.info("pageNum from doc: %s", doc.get("pageNum", "NOT PRESENT"))

tree = {
"doc_name": indexed_doc_name,
Expand All @@ -260,7 +247,9 @@ def index_long_document(pdf_path: Path, kb_dir: Path, doc_name: str | None = Non
# requires a page range, so pass "1-N".
page_count = _get_pdf_page_count(pdf_path)
try:
all_pages = _normalize_page_content(col.get_page_content(doc_id, f"1-{page_count}"))
all_pages = _normalize_page_content(
client.get_page_content(doc_id, f"1-{page_count}")
)
except Exception as exc:
logger.warning("Cloud get_page_content failed for %s: %s", pdf_path.name, exc)

Expand All @@ -281,12 +270,9 @@ def index_long_document(pdf_path: Path, kb_dir: Path, doc_name: str | None = Non
)
return IndexResult(doc_id=doc_id, description=description, tree=tree)
except BaseException:
# Best-effort: remove the blob this add created. A failure here (e.g. a
# second interrupt) only means the blob may stay orphaned — the original
# error still propagates so the caller (mutation coordinator) rolls back
# everything else it snapshotted.
# Best-effort: remove the blob this add created.
try:
col.delete_document(doc_id)
client.delete_document(doc_id)
except Exception:
logger.warning(
"PageIndex cleanup of %s failed after error; blob may be orphaned", doc_id
Expand All @@ -303,7 +289,7 @@ def index_long_document(pdf_path: Path, kb_dir: Path, doc_name: str | None = Non
_CLOUD_PAGE_MAX = 1_000_000


def _fetch_cloud_pages(col, doc_id: str) -> list[dict[str, Any]]:
def _fetch_cloud_pages(client: PageIndexClient, doc_id: str) -> list[dict[str, Any]]:
"""Fetch all OCR pages of a cloud doc, windowing around the 1000-page cap.

``get_page_content`` returns the whole document and uses its ``pages`` arg
Expand All @@ -322,7 +308,7 @@ def _fetch_cloud_pages(col, doc_id: str) -> list[dict[str, Any]]:
start = 1
while start <= _CLOUD_PAGE_MAX:
window = _normalize_page_content(
col.get_page_content(doc_id, f"{start}-{start + _CLOUD_PAGE_WINDOW - 1}")
client.get_page_content(doc_id, f"{start}-{start + _CLOUD_PAGE_WINDOW - 1}")
)
pages.extend(window)
if len(window) < _CLOUD_PAGE_WINDOW:
Expand All @@ -349,12 +335,12 @@ def prepare_cloud_import(doc_id: str, kb_dir: Path, path_key: str) -> CloudImpor
)

client = PageIndexClient(api_key=pageindex_api_key)
col = client.collection()

doc = col.get_document(doc_id, include_text=True)
cloud_name: str = doc.get("doc_name") or doc_id
description: str = doc.get("doc_description", "")
structure: list = doc.get("structure", [])
doc = client.get_document(doc_id)
cloud_name: str = doc.get("name") or doc_id
description: str = doc.get("description", "")
page_tree = client.get_tree(doc_id, include_text=True)
structure: list = page_tree.get("result", [])

registry = HashRegistry(kb_dir / ".openkb" / "hashes.json")
stem = _cloud_display_stem(cloud_name, doc_id)
Expand All @@ -366,7 +352,7 @@ def prepare_cloud_import(doc_id: str, kb_dir: Path, path_key: str) -> CloudImpor
"structure": structure,
}

all_pages = _fetch_cloud_pages(col, doc_id)
all_pages = _fetch_cloud_pages(client, doc_id)
if not all_pages:
raise RuntimeError(f"No page content returned from PageIndex Cloud for doc_id={doc_id}")

Expand Down
20 changes: 7 additions & 13 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
[build-system]
requires = ["hatchling", "hatch-vcs"]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "openkb"
dynamic = ["version"]
version = "0.4.6"
description = "OpenKB: Open LLM Knowledge Base, powered by PageIndex"
readme = "README.md"
requires-python = ">=3.10"
Expand Down Expand Up @@ -32,24 +32,21 @@ keywords = ["ai", "rag", "retrieval", "knowledge-base", "llm", "pageindex", "age
# returning empty Responses output (BerriAI/litellm#25429) and
# auto-injects GitHub Copilot IDE-auth headers.
dependencies = [
"pageindex==0.3.0.dev3",
"pageindex==0.2.19",
"markitdown[docx,pptx,xlsx,xls]==0.1.5",
"trafilatura==2.0.0",
"click==8.4.0",
"watchdog==6.0.0",
"litellm==1.87.2",
"openai-agents==0.17.3",
# openai 2.45.0 added a required `cache_write_tokens` field to
# InputTokensDetails that openai-agents 0.17.3 does not set, crashing
# usage parsing on every response (issue #187). Pin below 2.45 until
# openai-agents catches up.
"openai==2.44.0",
"litellm==1.102.1",
"openai-agents==0.20.0",
"openai==2.54.0",
"pyyaml==6.0.3",
"python-dotenv==1.2.2",
"json-repair==0.59.10",
"prompt_toolkit==3.0.52",
"rich==15.0.0",
"portalocker==3.2.0",
"pymupdf==1.28.2",
]

[project.urls]
Expand Down Expand Up @@ -81,9 +78,6 @@ dev = [
web = ["fastapi", "uvicorn", "python-multipart"]
api = ["openkb[web]"]

[tool.hatch.version]
source = "vcs"

# The Workbench web bundle (openkb/web) is git-ignored but must ship in the
# published package. `artifacts` force-includes it into both the sdist and the
# wheel when present (built by `npm run build`, e.g. in release CI). When it is
Expand Down
Loading