diff --git a/README.md b/README.md index 988bebda0..e2feb4863 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,8 @@ A "generator" reads from the compiled wiki and produces something usable: an ans `openkb query "..."` answers a single question with a grounded, cited answer from your wiki. `openkb chat` is interactive, an ongoing multi-turn session over the same wiki (`--resume`, `--list`, `--delete` to manage sessions). → Walked through with real saved output in **[`examples/commands/`](examples/commands/)** (query) and **[`examples/chat/`](examples/chat/)** (chat). +Retrieval is hybrid: the agent primarily navigates via `index.md`'s one-line summaries, and additionally has a `search_wiki` tool — a dependency-free BM25 full-text search over `concepts/`, `entities/`, and `summaries/` — for surfacing pages whose index summary doesn't mention a specific buried detail. It's additive, not a replacement, so recall can only improve over index-only navigation. + Inside a chat, type `/` to access slash commands (Tab to complete).
@@ -339,6 +341,31 @@ gemini skills install https://github.com/VectifyAI/OpenKB.git --path skills/open The skill is read-only. It won't run `openkb add`, `remove`, or `lint --fix` without you asking. See [`skills/openkb/SKILL.md`](skills/openkb/SKILL.md) for the full instruction set. +### Using with an MCP client + +For MCP-capable assistants (or any client that prefers typed tools over filesystem/CLI access), `openkb-mcp` starts a stdio MCP server exposing: + +- `list_taxonomy` / `list_documents` — semantic browsing of concepts/entities and summaries/explorations, each with their one-line brief. +- `get_content` — read wiki content by slug across all seven content kinds (concept/entity/summary/exploration/source/report/index); omit `kind` to search all of them and get one entry per match. +- `search_wiki` — tiered BM25 search over briefs/summaries/sources/explorations (see "Query & Chat" above for what "tiered" means). +- `get_status` — the active KB's absolute path and content counts (the only way to learn the KB's absolute path without shell access, since every other tool returns wiki-root-relative paths). +- `list_kbs` — every KB this server can address via the `kb` parameter. + +No index cache: every tool rebuilds its underlying index fresh on every call, same as the CLI. + +Every tool accepts an optional `kb` parameter (a registered KB name/alias, or an absolute KB root path) so one server process can serve multiple knowledge bases — omit it to use the KB resolved from the server's working directory or global default (today's behavior): + +```json +{ + "mcpServers": { + "openkb": { + "command": "openkb-mcp", + "cwd": "/path/to/your/kb" + } + } +} +``` + # REST API OpenKB ships a FastAPI service for HTTP clients. Install with `pip install -e ".[web]"`, then start with `python -m openkb.api`. The interactive API reference is at [`/docs`](http://127.0.0.1:7566/docs) (importable into Postman). diff --git a/openkb/agent/content.py b/openkb/agent/content.py new file mode 100644 index 000000000..46146c558 --- /dev/null +++ b/openkb/agent/content.py @@ -0,0 +1,557 @@ +"""Wiki content browsing/reading tools for the OpenKB agent. + +Split out of ``agent.tools`` (see ``tests/test_file_size.py``'s 800-line +module gate) — this module owns the "structured content access" surface +(taxonomy/document listings, unified content reads, KB status), while +``agent.tools`` keeps the lower-level, more heterogeneous tools (image +reads, KB-root file read/write, full-text search, artifact detection). +``agent.tools`` re-exports every public name here for backward +compatibility, so existing ``from openkb.agent.tools import ...`` call +sites are unaffected by this split. +""" + +from __future__ import annotations + +import contextlib +import json as _json +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from openkb import frontmatter + +# Maps a taxonomy "kind" to its wiki subdirectory. Single source of truth for +# list_taxonomy_items below. +_TAXONOMY_DIRS: dict[str, str] = {"concept": "concepts", "entity": "entities"} + +# Maps a "document" kind to its wiki subdirectory. Single source of truth for +# list_documents below. +_DOCUMENT_DIRS: dict[str, str] = {"summary": "summaries", "exploration": "explorations"} + +# All directory-backed kinds get_content() reads with an identical "whole +# file" strategy (kind -> subdirectory). "source" and "index" are handled +# separately by get_content itself: "source" because a document may be a +# short .md OR a paginated PageIndex .json with different `pages` semantics; +# "index" because it names a single root-level file, not a per-slug directory. +_CONTENT_DIRS: dict[str, str] = {**_TAXONOMY_DIRS, **_DOCUMENT_DIRS, "report": "reports"} +_CONTENT_KINDS = (*_CONTENT_DIRS, "source", "index") + + +def parse_pages(pages: str) -> list[int]: + """Parse a page specification string into a sorted, deduplicated list of page numbers. + + Args: + pages: Page spec such as ``"3-5,7,10-12"``. + + Returns: + Sorted list of positive page numbers, e.g. ``[3, 4, 5, 7, 10, 11, 12]``. + """ + result: set[int] = set() + for part in pages.split(","): + part = part.strip() + if "-" in part: + # Handle ranges like "3-5"; also handle negative numbers by only + # splitting on the first "-" that follows a digit. + segments = part.split("-") + # Re-join to handle leading negatives: segments[0] may be empty + # if part starts with "-". We just try to parse start/end. + # Silently skip malformed segments — parse_pages is a tolerant + # parser by design (user-supplied page specs may contain typos). + with contextlib.suppress(ValueError): + if len(segments) == 2: + start, end = int(segments[0]), int(segments[1]) + result.update(range(start, end + 1)) + elif len(segments) == 3 and segments[0] == "": + # e.g. "-1" split gives ['', '1'] + result.add(-int(segments[1])) + # More complex cases (e.g. negative range) are ignored. + else: + with contextlib.suppress(ValueError): + result.add(int(part)) + return sorted(n for n in result if n > 0) + + +@dataclass(frozen=True) +class TaxonomyItem: + """One persisted concept or entity page (never a pending candidate). + + ``PendingTopicsStore`` (see ``openkb.pending``) buffers not-yet-paged + concept/entity candidates separately from the compiled ``.md`` pages + under ``concepts/``/``entities/`` — this dataclass, and + :func:`list_taxonomy_items`, only ever surface the latter, so a caller + never sees an in-progress candidate as if it were a real page. + """ + + kind: Literal["concept", "entity"] + slug: str + path: str # wiki-root-relative, e.g. "concepts/attention.md" + brief: str + # Entity type (e.g. "person", "organization"); always None for concepts. + type: str | None = None + + +def list_taxonomy_items(wiki_root: str, kind: str | None = None) -> list[TaxonomyItem]: + """List persisted concept and/or entity pages with their one-line briefs. + + Intended as the first step of the search strategy: browse this compact, + semantically-scannable list and let the caller (an LLM) pick the + relevant slug(s) by meaning — this is deliberately not a keyword search + (see ``search_wiki`` for that, over summaries/sources only). + + Args: + wiki_root: Absolute path to the wiki root directory. + kind: Restrict to ``"concept"`` or ``"entity"``; ``None`` returns both. + + Returns: + Items sorted by kind, then slug. Empty list if the KB has neither + directory yet or both are empty. + + Raises: + ValueError: *kind* is neither ``None``, ``"concept"``, nor ``"entity"``. + """ + root = Path(wiki_root).resolve() + kinds = [kind] if kind else ["concept", "entity"] + for k in kinds: + if k not in _TAXONOMY_DIRS: + raise ValueError(f"Unknown kind {k!r}; expected 'concept' or 'entity'.") + + items: list[TaxonomyItem] = [] + for k in kinds: + directory = root / _TAXONOMY_DIRS[k] + if not directory.is_dir(): + continue + for md_file in sorted(directory.glob("*.md")): + text = md_file.read_text(encoding="utf-8") + fm = frontmatter.parse(text) + brief = frontmatter.resolve_description(fm) + etype = None + if k == "entity": + etype = str(fm.get("type") or "").strip().lower() or "other" + items.append( + TaxonomyItem( + kind=k, # type: ignore[arg-type] # validated against _TAXONOMY_DIRS above + slug=md_file.stem, + path=f"{_TAXONOMY_DIRS[k]}/{md_file.name}", + brief=brief, + type=etype, + ) + ) + return items + + +@dataclass(frozen=True) +class DocumentItem: + """One persisted summary or exploration page. + + Mirrors :class:`TaxonomyItem` for a different pair of kinds: summaries + (one per ingested document) and explorations (saved ``openkb query + --save`` answers). Listed separately from concepts/entities via its own + :func:`list_documents` rather than folded into :func:`list_taxonomy_items` + — concepts/entities are meant to be browsed in full by an LLM picking a + slug by meaning, while summaries/explorations are more commonly + discovered via ``search_wiki`` than browsed exhaustively; the different + usage pattern justifies a separate list function (see the tiered-search + design discussion — this dataclass only covers the LIST side of that + split, not the GET side, which is unified below in ``get_content``). + """ + + kind: Literal["summary", "exploration"] + slug: str + path: str # wiki-root-relative, e.g. "summaries/paper.md" + brief: str + + +def list_documents(wiki_root: str, kind: str | None = None) -> list[DocumentItem]: + """List persisted summary and/or exploration pages with their one-line briefs. + + Args: + wiki_root: Absolute path to the wiki root directory. + kind: Restrict to ``"summary"`` or ``"exploration"``; ``None`` returns both. + + Returns: + Items sorted by kind, then slug. Empty list if the KB has neither + directory yet or both are empty. + + Raises: + ValueError: *kind* is neither ``None``, ``"summary"``, nor ``"exploration"``. + """ + root = Path(wiki_root).resolve() + kinds = [kind] if kind else ["summary", "exploration"] + for k in kinds: + if k not in _DOCUMENT_DIRS: + raise ValueError(f"Unknown kind {k!r}; expected 'summary' or 'exploration'.") + + items: list[DocumentItem] = [] + for k in kinds: + directory = root / _DOCUMENT_DIRS[k] + if not directory.is_dir(): + continue + for md_file in sorted(directory.glob("*.md")): + text = md_file.read_text(encoding="utf-8") + fm = frontmatter.parse(text) + if k == "exploration": + # Explorations carry no description/brief frontmatter — the + # originally-saved question (see cli.save_exploration) IS + # the natural one-line brief. + brief = str(fm.get("query") or "").strip() + else: + brief = frontmatter.resolve_description(fm) + items.append( + DocumentItem( + kind=k, # type: ignore[arg-type] # validated against _DOCUMENT_DIRS above + slug=md_file.stem, + path=f"{_DOCUMENT_DIRS[k]}/{md_file.name}", + brief=brief, + ) + ) + return items + + +# Wiki subdirectories counted by get_kb_status — mirrors cli.print_status's +# subdirs list, plus "explorations" (which print_status doesn't count today). +_STATUS_SUBDIRS = ("sources", "summaries", "concepts", "entities", "reports", "explorations") + + +@dataclass(frozen=True) +class KbStatus: + """Structured KB status: same counts as the CLI's ``openkb status`` + (``cli.print_status``), returned as data instead of printed so non-CLI + callers — e.g. the MCP server's ``get_status`` tool — can use them + without pulling in ``cli.py``'s much heavier import chain (click, + litellm, the Agents SDK). + """ + + kb_dir: str + counts: dict[str, int] + total_indexed: int + + +def get_kb_status(kb_dir: str) -> KbStatus: + """Return structured status counts for the knowledge base at *kb_dir*. + + Args: + kb_dir: Absolute path to the KB root directory (containing ``wiki/``, + ``.openkb/``, and optionally ``raw/``). + + Returns: + ``.md`` file counts per wiki subdirectory (:data:`_STATUS_SUBDIRS`), + a ``"raw"`` count when ``raw/`` exists, and ``total_indexed`` from + the ``.openkb/hashes.json`` registry (``0`` if no registry exists + yet). + """ + root = Path(kb_dir).resolve() + wiki_dir = root / "wiki" + counts: dict[str, int] = {} + for subdir in _STATUS_SUBDIRS: + path = wiki_dir / subdir + counts[subdir] = len(list(path.glob("*.md"))) if path.is_dir() else 0 + + raw_dir = root / "raw" + if raw_dir.is_dir(): + counts["raw"] = len([f for f in raw_dir.iterdir() if f.is_file()]) + + hashes_file = root / ".openkb" / "hashes.json" + total_indexed = 0 + if hashes_file.exists(): + hashes = _json.loads(hashes_file.read_text(encoding="utf-8")) + total_indexed = len(hashes) + + return KbStatus(kb_dir=str(root), counts=counts, total_indexed=total_indexed) + + +@dataclass(frozen=True) +class ContentEntry: + """One match from :func:`get_content`. + + Always returned as part of a list, even when there is exactly one + match — callers never need to branch on "single result vs. list of + results" depending on how many kinds matched. ``error`` is a soft, + per-entry explanation (not found, or ``pages`` used/missing where it + shouldn't be) — the only case :func:`get_content` raises an exception + for is an unrecognized ``kind`` value (a caller bug, not a normal + "need more info" outcome). + """ + + kind: str + path: str # wiki-root-relative + content: str | None + error: str | None = None + + +def _read_pageindex_pages(json_path: Path, pages: str, doc_name: str) -> str: + """Return formatted content for the requested *pages* of a PageIndex doc. + + Reads a JSON array of ``{"page": int, "content": str}`` objects (see + ``get_content``'s "source" handling) with an optional ``"images"`` list + of ``{"path": str, ...}`` objects. + + Returns a "no content found" message (not an exception) when the + requested pages have no matching entries — a typo'd page range is a + normal, expected outcome, not a caller bug. + """ + data = _json.loads(json_path.read_text(encoding="utf-8")) + requested = set(parse_pages(pages)) + matches = [entry for entry in data if entry.get("page") in requested] + + if not matches: + return f"No content found for pages {pages} in {doc_name}." + + parts: list[str] = [] + for entry in matches: + page_num = entry["page"] + content = entry.get("content", "") + block = f"[Page {page_num}]\n{content}" + images = entry.get("images") + if images: + paths = ", ".join(img["path"] for img in images if "path" in img) + if paths: + block += f"\n[Images: {paths}]" + parts.append(block) + + return "\n\n".join(parts) + "\n\n" + + +def _resolve_source_entries( + slug: str, root: Path, pages: str | None, explicit: bool +) -> list[ContentEntry]: + """Resolve a "source" kind match — short ``.md`` or paginated PageIndex ``.json``. + + Auto-detects which of the two a document is, so the caller never has to + know/choose between them up front: ``pages`` is required for the long + (PageIndex) case, forbidden for the short case — a soft ``error`` on the + single returned entry explains which, rather than the caller picking the + wrong one of two differently-shaped functions (the previous split + between ``read_wiki_file`` and ``get_wiki_page_content``). + """ + json_path = (root / "sources" / f"{slug}.json").resolve() + md_path = (root / "sources" / f"{slug}.md").resolve() + + if not json_path.is_relative_to(root) or not md_path.is_relative_to(root): + return [ + ContentEntry( + kind="source", + path=f"sources/{slug}", + content=None, + error="Access denied: path escapes wiki root.", + ) + ] + + if json_path.exists(): + rel_path = f"sources/{slug}.json" + if pages is None: + return [ + ContentEntry( + kind="source", + path=rel_path, + content=None, + error=( + "This is a long (PageIndex) document; pages is required " + "(e.g. pages='3-5,7'). Use search_wiki(scope=['sources']) " + "for a locator naming the right page, or list_documents " + "for this document's overview." + ), + ) + ] + return [ + ContentEntry( + kind="source", path=rel_path, content=_read_pageindex_pages(json_path, pages, slug) + ) + ] + + if md_path.exists(): + rel_path = f"sources/{slug}.md" + if pages is not None and explicit: + # Only an error when the caller explicitly asked for kind="source" + # with pages set (a genuine mistake) — during a kind=None fan-out, + # pages was probably meant for a different (long) source match + # elsewhere, so a short doc here just ignores it like every other + # non-"source" kind already does. + return [ + ContentEntry( + kind="source", + path=rel_path, + content=None, + error=( + "pages is not valid for a short (non-paginated) source document; omit it." + ), + ) + ] + return [ + ContentEntry(kind="source", path=rel_path, content=md_path.read_text(encoding="utf-8")) + ] + + if explicit: + return [ + ContentEntry( + kind="source", + path=f"sources/{slug}.md", + content=None, + error=f"File not found: sources/{slug}.md", + ) + ] + return [] + + +def get_content( + slug: str, + wiki_root: str, + kind: str | None = None, + pages: str | None = None, +) -> list[ContentEntry]: + """Read wiki content by slug — one function for every content kind. + + Replaces the previously separate ``get_taxonomy_item``/``read_wiki_file``/ + ``get_wiki_page_content`` split: ``read_wiki_file`` and + ``get_wiki_page_content`` now delegate to this function (kept for + backward compatibility — both predate this change and are already + released); ``get_taxonomy_item`` is gone (it was still unreleased). + + Args: + slug: Page slug (filename without extension), e.g. ``"attention"``. + For "source", identical to the paired summary's slug (a summary + and its source describe the same document 1:1) — so a plain + ``get_content(slug, wiki_root)`` without ``kind`` commonly + returns both as separate entries, not a single "first match". + wiki_root: Absolute path to the wiki root directory. + kind: One of ``"concept"``, ``"entity"``, ``"summary"``, + ``"exploration"``, ``"source"``, ``"report"``, ``"index"``. + ``None`` (default) searches ALL seven and returns one entry per + match found — 0, 1, or several (mirrors ``list_taxonomy_items``/ + ``list_documents``' "kind=None returns a combined list" behavior, + rather than a "first match wins" precedence that would silently + drop e.g. the source when a summary shares its slug). ``"report"`` + and ``"index"`` are gettable like any other kind but deliberately + have no ``list_*`` counterpart — pure diagnostic/meta artifacts + with no meaningful one-line brief to browse. + pages: Only meaningful for a ``"source"`` match — required for a long + (PageIndex) document, forbidden otherwise; see + :func:`_resolve_source_entries`. Ignored (has no effect) for + every other kind — set alongside a non-"source" kind, it is + silently dropped rather than erroring, since ``kind=None`` fans + out across kinds where "pages" simply isn't applicable to most + of them. + + Returns: + One :class:`ContentEntry` per match — always a list, even for a + single match, so callers never branch on the return shape. + + Raises: + ValueError: *kind* is not one of the recognized values. + """ + root = Path(wiki_root).resolve() + kinds = [kind] if kind else list(_CONTENT_KINDS) + for k in kinds: + if k not in _CONTENT_KINDS: + raise ValueError(f"Unknown kind {k!r}; expected one of {_CONTENT_KINDS}.") + + entries: list[ContentEntry] = [] + for k in kinds: + if k == "source": + entries.extend(_resolve_source_entries(slug, root, pages, explicit=kind is not None)) + continue + + if k == "index": + if slug != "index": + if kind is not None: + entries.append( + ContentEntry( + kind="index", + path="index.md", + content=None, + error="File not found: index.md", + ) + ) + continue + index_path = (root / "index.md").resolve() + if not index_path.is_relative_to(root) or not index_path.exists(): + if kind is not None: + entries.append( + ContentEntry( + kind="index", + path="index.md", + content=None, + error="File not found: index.md", + ) + ) + continue + if pages is not None and kind is not None: + entries.append( + ContentEntry( + kind="index", + path="index.md", + content=None, + error="pages is not valid for kind='index'.", + ) + ) + continue + entries.append( + ContentEntry( + kind="index", path="index.md", content=index_path.read_text(encoding="utf-8") + ) + ) + continue + + # concept/entity/summary/exploration/report: identical whole-file lookup. + directory = _CONTENT_DIRS[k] + rel_path = f"{directory}/{slug}.md" + path = (root / directory / f"{slug}.md").resolve() + if not path.is_relative_to(root): + if kind is not None: + entries.append( + ContentEntry( + kind=k, + path=rel_path, + content=None, + error="Access denied: path escapes wiki root.", + ) + ) + continue + if not path.exists(): + if kind is not None: + entries.append( + ContentEntry( + kind=k, path=rel_path, content=None, error=f"File not found: {rel_path}" + ) + ) + continue + if pages is not None and kind is not None: + entries.append( + ContentEntry( + kind=k, + path=rel_path, + content=None, + error=f"pages is only valid for kind='source', not {k!r}.", + ) + ) + continue + entries.append( + ContentEntry(kind=k, path=rel_path, content=path.read_text(encoding="utf-8")) + ) + return entries + + +_CONTENT_DIR_TO_KIND = {v: k for k, v in _CONTENT_DIRS.items()} + + +def _kind_and_slug_from_path(path: str) -> tuple[str, str] | None: + """Map a wiki-root-relative *path* to a ``(kind, slug)`` pair for + :func:`get_content`, or ``None`` if it doesn't cleanly fall under one of + get_content's known directories/files (defensive fallback only — every + path in the current wiki schema, including ``index.md`` and + ``reports/*.md``, maps cleanly; this stays conservative for anything + unexpected, e.g. path traversal or an unforeseen nesting, rather than + guessing). + """ + normalized = path.replace("\\", "/").strip("/") + if normalized == "index.md": + return "index", "index" + if "/" not in normalized: + return None + top, rest = normalized.split("/", 1) + if "/" in rest: + return None # only a single flat filename per kind is recognized + kind = _CONTENT_DIR_TO_KIND.get(top) or ("source" if top == "sources" else None) + if kind is None: + return None + slug = rest[: -len(Path(rest).suffix)] if Path(rest).suffix else rest + return kind, slug diff --git a/openkb/agent/query.py b/openkb/agent/query.py index da1a939ef..e5cd79cd5 100644 --- a/openkb/agent/query.py +++ b/openkb/agent/query.py @@ -14,6 +14,9 @@ read_wiki_image, write_kb_file, ) +from openkb.agent.tools import ( + search_wiki as search_wiki_impl, +) from openkb.config import LlmCredentialBundle, resolve_model_settings from openkb.schema import get_agents_md @@ -33,18 +36,23 @@ 3. Read concept pages (concepts/) for cross-document synthesis. 4. For "who/what is X" questions about a specific named person, organization, place, or product, read the matching page in entities/ first. -5. When you need detailed source document content, each summary page has a +5. If index.md's one-line summaries don't surface a specific detail you + need (a niche term, an exact figure, a buried fact), use + search_wiki(query) — a keyword-level full-text search over + concepts/entities/summaries. This is a hybrid fallback: use it in + addition to, not instead of, index.md navigation. +6. When you need detailed source document content, each summary page has a `full_text` frontmatter field with the path to the original document content: - Short documents (doc_type: short): read_file with that path. - PageIndex documents (doc_type: pageindex): use get_page_content(doc_name, pages) with tight page ranges. The summary shows document tree structure with page ranges to help you target. Never fetch the whole document. -6. Source content may reference images. Short-doc .md pages link them +7. Source content may reference images. Short-doc .md pages link them note-relative (e.g. ![image](images/doc/file.png), resolved from wiki/sources/); long-doc JSON page metadata lists them wiki-root-relative (e.g. sources/images/doc/file.png). Pass either form as seen to the get_image tool — it accepts both. -7. Synthesize a clear, concise, well-cited answer grounded in wiki content. +8. Synthesize a clear, concise, well-cited answer grounded in wiki content. Answer based only on wiki content. Be concise. Before each tool call, output one short sentence explaining the reason. @@ -83,6 +91,19 @@ def get_page_content(doc_name: str, pages: str) -> str: """ return get_wiki_page_content(doc_name, pages, wiki_root) + @function_tool + def search_wiki(query: str) -> str: + """Full-text (BM25) keyword search over concepts/entities/summaries. + + Hybrid fallback for when index.md's one-line summaries don't surface + a specific buried detail (a niche term, an exact figure, a fact). + Use in addition to, not instead of, index.md navigation. + + Args: + query: Free-text search query (keywords or a natural-language question). + """ + return search_wiki_impl(query, wiki_root) + @function_tool def get_image(image_path: str) -> ToolOutputImage | ToolOutputText: """View an image from the wiki. @@ -117,7 +138,7 @@ def get_image(image_path: str) -> ToolOutputImage | ToolOutputText: return Agent( name="wiki-query", instructions=instructions, - tools=[read_file, get_page_content, get_image], + tools=[read_file, get_page_content, search_wiki, get_image], model=f"litellm/{model}", model_settings=ModelSettings(**model_settings), ) diff --git a/openkb/agent/tools.py b/openkb/agent/tools.py index eedd388de..6fa8843a0 100644 --- a/openkb/agent/tools.py +++ b/openkb/agent/tools.py @@ -7,10 +7,41 @@ from __future__ import annotations -import contextlib import json as _json from pathlib import Path, PurePosixPath +# Re-exported for backward compatibility — these used to be defined directly +# in this module; see openkb.agent.content's docstring for why they moved. +from openkb.agent.content import ( + ContentEntry as ContentEntry, +) +from openkb.agent.content import ( + DocumentItem as DocumentItem, +) +from openkb.agent.content import ( + KbStatus as KbStatus, +) +from openkb.agent.content import ( + TaxonomyItem as TaxonomyItem, +) +from openkb.agent.content import ( + _kind_and_slug_from_path, +) +from openkb.agent.content import ( + get_content as get_content, +) +from openkb.agent.content import ( + get_kb_status as get_kb_status, +) +from openkb.agent.content import ( + list_documents as list_documents, +) +from openkb.agent.content import ( + list_taxonomy_items as list_taxonomy_items, +) +from openkb.agent.content import ( + parse_pages as parse_pages, +) from openkb.locks import atomic_write_text @@ -48,6 +79,18 @@ def read_wiki_file(path: str, wiki_root: str) -> str: Returns: File contents as a string, or ``"File not found: {path}"`` if missing. """ + mapped = _kind_and_slug_from_path(path) + if mapped is not None: + kind, slug = mapped + entry = get_content(slug, wiki_root, kind=kind)[0] + if entry.error is not None: + return entry.error + return entry.content or "" + + # Defensive fallback for anything outside get_content's 7 known kinds + # (path traversal, or a path shape the current wiki schema doesn't + # produce) — kept so this function's behavior never regresses for an + # unexpected path, even though every real wiki page maps cleanly above. root = Path(wiki_root).resolve() full_path = (root / path).resolve() if not full_path.is_relative_to(root): @@ -57,40 +100,6 @@ def read_wiki_file(path: str, wiki_root: str) -> str: return full_path.read_text(encoding="utf-8") -def parse_pages(pages: str) -> list[int]: - """Parse a page specification string into a sorted, deduplicated list of page numbers. - - Args: - pages: Page spec such as ``"3-5,7,10-12"``. - - Returns: - Sorted list of positive page numbers, e.g. ``[3, 4, 5, 7, 10, 11, 12]``. - """ - result: set[int] = set() - for part in pages.split(","): - part = part.strip() - if "-" in part: - # Handle ranges like "3-5"; also handle negative numbers by only - # splitting on the first "-" that follows a digit. - segments = part.split("-") - # Re-join to handle leading negatives: segments[0] may be empty - # if part starts with "-". We just try to parse start/end. - # Silently skip malformed segments — parse_pages is a tolerant - # parser by design (user-supplied page specs may contain typos). - with contextlib.suppress(ValueError): - if len(segments) == 2: - start, end = int(segments[0]), int(segments[1]) - result.update(range(start, end + 1)) - elif len(segments) == 3 and segments[0] == "": - # e.g. "-1" split gives ['', '1'] - result.add(-int(segments[1])) - # More complex cases (e.g. negative range) are ignored. - else: - with contextlib.suppress(ValueError): - result.add(int(part)) - return sorted(n for n in result if n > 0) - - def get_wiki_page_content(doc_name: str, pages: str, wiki_root: str) -> str: """Return formatted content for specified pages of a document. @@ -105,34 +114,45 @@ def get_wiki_page_content(doc_name: str, pages: str, wiki_root: str) -> str: Returns: Formatted page content, or an error message string. + + Delegates to :func:`get_content` (``kind="source"``) — kept as a thin, + backward-compatible wrapper since (unlike ``get_taxonomy_item``) this + function predates the unified ``get_content`` and is already released. """ - root = Path(wiki_root).resolve() - target = (root / "sources" / f"{doc_name}.json").resolve() - if not target.is_relative_to(root): - return "Access denied: path escapes wiki root." - if not target.exists(): - return f"File not found: sources/{doc_name}.json" - - data = _json.loads(target.read_text(encoding="utf-8")) - requested = set(parse_pages(pages)) - matches = [entry for entry in data if entry.get("page") in requested] - - if not matches: - return f"No content found for pages {pages} in {doc_name}." - - parts: list[str] = [] - for entry in matches: - page_num = entry["page"] - content = entry.get("content", "") - block = f"[Page {page_num}]\n{content}" - images = entry.get("images") - if images: - paths = ", ".join(img["path"] for img in images if "path" in img) - if paths: - block += f"\n[Images: {paths}]" - parts.append(block) - - return "\n\n".join(parts) + "\n\n" + entry = get_content(doc_name, wiki_root, kind="source", pages=pages)[0] + if entry.error is not None: + return entry.error + return entry.content or "" + + +def search_wiki(query: str, wiki_root: str, top_k: int = 5) -> str: + """Full-text (BM25) search over concepts/entities/summaries wiki pages. + + Hybrid retrieval helper: complements index.md-driven navigation by + surfacing pages whose one-line index summary doesn't mention a specific + buried detail the query is looking for (a niche term, a figure, an exact + fact). Additive — use alongside, not instead of, index.md navigation. + + Args: + query: Free-text search query (keywords or a natural-language question). + wiki_root: Absolute path to the wiki root directory. + top_k: Maximum number of ranked results to return. + + Returns: + A formatted, ranked list of page hits (wikilink, title, snippet), or + a message indicating no matches were found. + """ + from openkb.fulltext_index import WikiFullTextIndex + + hits = WikiFullTextIndex(wiki_root).search(query, top_k=top_k) + if not hits: + return "No matching pages found." + + lines = [] + for i, hit in enumerate(hits, start=1): + wikilink = hit.path[:-3] if hit.path.endswith(".md") else hit.path + lines.append(f"{i}. [[{wikilink}]] — {hit.title} (score: {hit.score})\n {hit.snippet}") + return "\n".join(lines) _MIME_TYPES = { diff --git a/openkb/frontmatter.py b/openkb/frontmatter.py index 34c504bac..9143f2f92 100644 --- a/openkb/frontmatter.py +++ b/openkb/frontmatter.py @@ -110,3 +110,29 @@ def set_line(fm_block: str, key: str, value: str) -> str: def drop_line(fm_block: str, key: str) -> str: """Remove any ``key:`` line from a frontmatter block (no-op if absent).""" return re.sub(rf"^{re.escape(key)}:.*\n?", "", fm_block, flags=re.MULTILINE) + + +def resolve_description(fm: dict) -> str: + """Return a non-empty description string from a parsed frontmatter dict. + + Checks ``description`` first, then the legacy ``brief`` key (pre-migration + pages). Returns an empty string when neither key holds a non-blank value. + Mirrors ``agent.compiler._resolve_description`` — kept as a separate, + dependency-free copy here so callers outside the compiler (search/taxonomy + tooling) don't need to import from ``agent.compiler``, which is under + active, unrelated development. + """ + for key in ("description", "brief"): + v = fm.get(key) + if isinstance(v, str) and v.strip(): + return v.strip() + return "" + + +def body_only(text: str) -> str: + """Return *text* with any leading YAML frontmatter block removed. + + Returns *text* unchanged when it has no well-formed frontmatter. + """ + parts = split(text) + return parts[1] if parts is not None else text diff --git a/openkb/fulltext_index.py b/openkb/fulltext_index.py new file mode 100644 index 000000000..5c04a6b60 --- /dev/null +++ b/openkb/fulltext_index.py @@ -0,0 +1,461 @@ +"""Dependency-free BM25 full-text index over compiled wiki pages. + +Hybrid retrieval: the query/chat agent's primary search strategy is +``index.md`` navigation (one-line summaries pointing at pages to read). That +strategy loses recall for details buried deep in a page body that the +one-liner doesn't mention. This module adds an additive, keyword-level +fallback — a BM25 index over the same compiled pages — exposed to the agent +as the ``search_wiki`` tool (see ``openkb.agent.tools.search_wiki``). It is a +union with index-driven navigation, not a replacement, so recall can only +improve relative to index-only navigation, never regress. + +Concepts and entities are deliberately excluded from full-text search (see +:class:`TieredWikiSearch` below) — they are found by semantic browsing +(``list_taxonomy_items``/``get_content`` in ``agent.tools``), not keyword +search, so :class:`WikiFullTextIndex` (kept for backward compatibility with +the original single-tier ``search_wiki`` tool) and :class:`TieredWikiSearch` +cover different, non-overlapping surfaces: + +- :class:`WikiFullTextIndex` — the original combined BM25 index over + ``concepts/`` + ``entities/`` + ``summaries/`` (:data:`PAGE_CONTENT_DIRS`). +- :class:`TieredWikiSearch` — four independent BM25 tiers, each scoped to a + different part of a document's lifecycle so a query only "wastes" recall + budget on the granularity it's actually likely to match at: + 1. ``briefs`` — one-line ``description``/``brief`` frontmatter per + ``summaries/*.md`` (same short text ``index.md`` shows). High precision, + low recall — good for on-topic queries, filters out incidental + word-frequency noise from long documents. + 2. ``summaries`` — full body of ``summaries/*.md``. Higher recall for + specific terms/figures the one-liner omits. + 3. ``sources`` — raw ``sources/*.md`` (whole file) and ``sources/*.json`` + PageIndex documents (indexed **per page**, not per document, so a hit + can point at an exact page via a :class:`Locator` instead of forcing a + re-score over an entire long document). Covers details that never make + it into a summary at all (creation dates, authors, exact field names). + 4. ``explorations`` — full body of ``explorations/*.md`` (saved + ``openkb query --save`` answers). Its own tier rather than folded into + ``summaries`` — an exploration is a previously-synthesized answer, not + a document summary, and keeping it a separate tier means a hit stays + unambiguously labeled as one or the other by which tier surfaced it. + +No new dependency: OpenKB pins dependencies exactly and vets each one +deliberately (see ``pyproject.toml``), and BM25 over a few hundred wiki pages +is cheap enough in pure Python that a search-library dependency (e.g. Whoosh) +isn't warranted. +""" + +from __future__ import annotations + +import json as _json +import math +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from openkb import frontmatter +from openkb.schema import PAGE_CONTENT_DIRS + +_TOKEN_RE = re.compile(r"[a-z0-9]+") + +# Standard BM25 hyperparameters (Robertson/Sparck-Jones defaults). +_K1 = 1.5 +_B = 0.75 + +_SNIPPET_RADIUS = 80 # characters of context on each side of the first match + +# Valid `scope` values for TieredWikiSearch.search() — one BM25 tier each. +TIERED_SCOPES = ("briefs", "summaries", "sources", "explorations") + + +def _tokenize(text: str) -> list[str]: + """Lowercase, alphanumeric-only tokenization (no stemming).""" + return _TOKEN_RE.findall(text.lower()) + + +def _extract_title(text: str) -> str | None: + """Return the first ``# heading`` line's text, or ``None``.""" + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("# "): + return stripped[2:].strip() + return None + + +def _make_snippet(text: str, query_terms: list[str]) -> str: + """Return a short excerpt around the first query-term match in *text*.""" + lowered = text.lower() + match_pos = -1 + for term in query_terms: + pos = lowered.find(term) + if pos != -1 and (match_pos == -1 or pos < match_pos): + match_pos = pos + if match_pos == -1: + collapsed = " ".join(text.split()) + truncated = collapsed[: _SNIPPET_RADIUS * 2] + suffix = "…" if len(collapsed) > _SNIPPET_RADIUS * 2 else "" + return truncated + suffix + + start = max(0, match_pos - _SNIPPET_RADIUS) + end = min(len(text), match_pos + _SNIPPET_RADIUS) + collapsed = " ".join(text[start:end].split()) + prefix = "…" if start > 0 else "" + suffix = "…" if end < len(text) else "" + return f"{prefix}{collapsed}{suffix}" + + +@dataclass(frozen=True) +class Locator: + """Points at a specific location within a hit's page for a follow-up read. + + ``kind="line"``: 1-based line number within a Markdown file (computed at + query time from the first query-term match). ``kind="page"``: 1-based + PageIndex page number within a long-doc ``sources/*.json`` array — fixed + at indexing time (one page = one BM25 "document"), and directly usable + with ``get_page_content(doc_name, pages=str(value))``. + """ + + kind: Literal["line", "page"] + value: int + + +@dataclass(frozen=True) +class SearchHit: + """A single BM25 search result over a wiki page (or a PageIndex page).""" + + path: str # wiki-root-relative, e.g. "concepts/attention.md" + title: str + score: float + snippet: str + locator: Locator | None = None + + +@dataclass(frozen=True) +class _IndexedPage: + path: str + title: str + text: str + tokens: list[str] + # Fixed at indexing time for pseudo-documents that are inherently + # page-scoped (one PageIndex page = one _IndexedPage); None otherwise, in + # which case _BM25Scorer computes a "line" Locator at query time instead. + fixed_locator: Locator | None = None + + +def _find_line_locator(text: str, query_terms: list[str]) -> Locator | None: + """Return a 1-based ``line`` Locator for the first query-term match line. + + Returns ``None`` if no line contains any query term (can happen when the + match is only visible after tokenization, e.g. across punctuation). + """ + for line_no, line in enumerate(text.splitlines(), start=1): + lowered = line.lower() + if any(term in lowered for term in query_terms): + return Locator(kind="line", value=line_no) + return None + + +class _BM25Scorer: + """Pure BM25 ranking (Robertson/Sparck-Jones) over a fixed page list. + + Extracted from the original :class:`WikiFullTextIndex` so the same + scoring math is shared between the legacy combined index and + :class:`TieredWikiSearch`'s three independent tiers, without duplicating + the formula. No I/O — callers build the ``pages`` list. + """ + + def __init__(self, pages: list[_IndexedPage]) -> None: + self._pages = pages + self._df: dict[str, int] = {} + self._avgdl = 0.0 + if not pages: + return + self._avgdl = sum(len(page.tokens) for page in pages) / len(pages) + for page in pages: + for term in set(page.tokens): + self._df[term] = self._df.get(term, 0) + 1 + + def _idf(self, term: str) -> float: + n = len(self._pages) + df = self._df.get(term, 0) + # +1 smoothing keeps idf non-negative even for very common terms. + return math.log((n - df + 0.5) / (df + 0.5) + 1) + + def _score(self, query_terms: list[str], page: _IndexedPage) -> float: + dl = len(page.tokens) + tf: dict[str, int] = {} + for term in page.tokens: + tf[term] = tf.get(term, 0) + 1 + + score = 0.0 + for term in query_terms: + f = tf.get(term, 0) + if f == 0: + continue + idf = self._idf(term) + numerator = f * (_K1 + 1) + denominator = f + _K1 * (1 - _B + _B * dl / self._avgdl) + score += idf * (numerator / denominator) + return score + + def search(self, query: str, top_k: int = 5) -> list[SearchHit]: + """Return the ``top_k`` highest-scoring pages for *query* (BM25). + + Args: + query: Free-text search query (keywords or a question). + top_k: Maximum number of results to return. + + Returns: + Ranked hits, highest score first. Empty if the query has no + tokens or the index has no pages. + """ + query_terms = _tokenize(query) + if not query_terms or not self._pages: + return [] + + scored = [(self._score(query_terms, page), page) for page in self._pages] + scored = [(score, page) for score, page in scored if score > 0] + scored.sort(key=lambda item: item[0], reverse=True) + + return [ + SearchHit( + path=page.path, + title=page.title, + score=round(score, 3), + snippet=_make_snippet(page.text, query_terms), + locator=page.fixed_locator or _find_line_locator(page.text, query_terms), + ) + for score, page in scored[:top_k] + ] + + +class WikiFullTextIndex: + """In-memory BM25 index over :data:`PAGE_CONTENT_DIRS` wiki pages. + + Rebuilt fresh on construction — cheap enough at the wiki sizes this + pattern targets (hundreds of pages); no on-disk cache or incremental + update is needed. Kept for backward compatibility with the original + (PR #234) single-tier ``search_wiki`` tool — new callers should prefer + :class:`TieredWikiSearch`, which separates concepts/entities (browsed via + ``list_taxonomy_items``, not indexed here) from summaries/sources. + """ + + def __init__(self, wiki_root: str | Path) -> None: + self._wiki_root = Path(wiki_root).resolve() + self._pages: list[_IndexedPage] = _build_pages_from_dirs(self._wiki_root, PAGE_CONTENT_DIRS) + self._scorer = _BM25Scorer(self._pages) + + def search(self, query: str, top_k: int = 5) -> list[SearchHit]: + """Return the ``top_k`` highest-scoring pages for *query* (BM25). + + Args: + query: Free-text search query (keywords or a question). + top_k: Maximum number of results to return. + + Returns: + Ranked hits, highest score first. Empty if the query has no + tokens or the index has no pages. + """ + return self._scorer.search(query, top_k=top_k) + + +def _build_pages_from_dirs(wiki_root: Path, subdirs: tuple[str, ...]) -> list[_IndexedPage]: + """Index every ``*.md`` file's full text under each of *subdirs*.""" + pages: list[_IndexedPage] = [] + for subdir in subdirs: + target = wiki_root / subdir + if not target.is_dir(): + continue + for md_file in sorted(target.glob("*.md")): + text = md_file.read_text(encoding="utf-8") + tokens = _tokenize(text) + if not tokens: + continue + title = _extract_title(text) or md_file.stem + path = f"{subdir}/{md_file.name}" + pages.append(_IndexedPage(path=path, title=title, text=text, tokens=tokens)) + return pages + + +def _build_brief_pages(wiki_root: Path) -> list[_IndexedPage]: + """One pseudo-document per ``summaries/*.md``, text = its one-line brief. + + Uses the ``description``/legacy ``brief`` frontmatter field — the same + short text ``index.md``'s ``## Documents`` section shows — not the full + body. Pages without a resolvable brief are skipped (nothing to index). + """ + summaries_dir = wiki_root / "summaries" + if not summaries_dir.is_dir(): + return [] + pages: list[_IndexedPage] = [] + for md_file in sorted(summaries_dir.glob("*.md")): + text = md_file.read_text(encoding="utf-8") + brief = frontmatter.resolve_description(frontmatter.parse(text)) + tokens = _tokenize(brief) + if not tokens: + continue + title = _extract_title(text) or md_file.stem + pages.append( + _IndexedPage(path=f"summaries/{md_file.name}", title=title, text=brief, tokens=tokens) + ) + return pages + + +def _build_summary_pages(wiki_root: Path) -> list[_IndexedPage]: + """One document per ``summaries/*.md``, text = full body (no frontmatter).""" + summaries_dir = wiki_root / "summaries" + if not summaries_dir.is_dir(): + return [] + pages: list[_IndexedPage] = [] + for md_file in sorted(summaries_dir.glob("*.md")): + text = md_file.read_text(encoding="utf-8") + body = frontmatter.body_only(text) + tokens = _tokenize(body) + if not tokens: + continue + title = _extract_title(text) or md_file.stem + pages.append( + _IndexedPage(path=f"summaries/{md_file.name}", title=title, text=body, tokens=tokens) + ) + return pages + + +def _build_exploration_pages(wiki_root: Path) -> list[_IndexedPage]: + """One document per ``explorations/*.md``, text = full saved-answer body. + + Its own independent tier — not merged into ``summaries``/``briefs`` — + so a hit here is unambiguously a previously-saved query answer rather + than a document summary, even though both are searched the same way + (full body, BM25). Title is the original saved ``query:`` frontmatter + value (explorations are freeform answers with no "# heading" + convention to fall back on as reliably as summaries/sources have). + """ + explorations_dir = wiki_root / "explorations" + if not explorations_dir.is_dir(): + return [] + pages: list[_IndexedPage] = [] + for md_file in sorted(explorations_dir.glob("*.md")): + text = md_file.read_text(encoding="utf-8") + body = frontmatter.body_only(text) + tokens = _tokenize(body) + if not tokens: + continue + query = str(frontmatter.parse(text).get("query") or "").strip() + title = query or _extract_title(text) or md_file.stem + pages.append( + _IndexedPage(path=f"explorations/{md_file.name}", title=title, text=body, tokens=tokens) + ) + return pages + + +def _build_source_pages(wiki_root: Path) -> list[_IndexedPage]: + """Sources tier: ``sources/*.md`` (whole file) + ``sources/*.json`` (per page). + + A PageIndex ``sources/*.json`` document is a JSON array of + ``{"page": int, "content": str, ...}`` objects (see + ``agent.tools.get_wiki_page_content``). Each page is indexed as its own + ``_IndexedPage`` with a fixed ``page`` :class:`Locator` — never the whole + document as one BM25 unit — so a hit points at an exact page instead of + diluting the score across a potentially very long document, and so the + locator is directly usable with ``get_page_content(doc_name, pages=...)``. + """ + sources_dir = wiki_root / "sources" + if not sources_dir.is_dir(): + return [] + pages: list[_IndexedPage] = [] + for src_file in sorted(sources_dir.iterdir()): + if src_file.suffix == ".md": + text = src_file.read_text(encoding="utf-8") + tokens = _tokenize(text) + if not tokens: + continue + title = _extract_title(text) or src_file.stem + pages.append( + _IndexedPage(path=f"sources/{src_file.name}", title=title, text=text, tokens=tokens) + ) + elif src_file.suffix == ".json": + pages.extend(_index_pageindex_source(src_file)) + return pages + + +def _index_pageindex_source(src_file: Path) -> list[_IndexedPage]: + """Return one ``_IndexedPage`` per page of a PageIndex ``sources/*.json`` doc. + + Tolerant of malformed/foreign JSON (skips, doesn't raise) — a hand-edited + or unexpected file under ``sources/`` shouldn't break indexing of the rest + of the KB. + """ + try: + data = _json.loads(src_file.read_text(encoding="utf-8")) + except (_json.JSONDecodeError, OSError, UnicodeDecodeError): + return [] + if not isinstance(data, list): + return [] + + pages: list[_IndexedPage] = [] + for entry in data: + if not isinstance(entry, dict): + continue + page_num = entry.get("page") + content = entry.get("content", "") + if not isinstance(page_num, int) or not isinstance(content, str): + continue + tokens = _tokenize(content) + if not tokens: + continue + pages.append( + _IndexedPage( + path=f"sources/{src_file.name}", + title=f"{src_file.stem} (page {page_num})", + text=content, + tokens=tokens, + fixed_locator=Locator(kind="page", value=page_num), + ) + ) + return pages + + +class TieredWikiSearch: + """Four independent BM25 tiers over ``summaries/``, ``sources/``, and + ``explorations/``. + + Concepts and entities are intentionally out of scope here — they are + browsed semantically via ``list_taxonomy_items``/``get_content`` + (``agent.tools``), not keyword-searched. Rebuilt fresh on construction, + same no-cache rationale as :class:`WikiFullTextIndex` (see module + docstring); cheap at the wiki sizes this pattern targets. + """ + + def __init__(self, wiki_root: str | Path) -> None: + wiki_root = Path(wiki_root).resolve() + self._scorers: dict[str, _BM25Scorer] = { + "briefs": _BM25Scorer(_build_brief_pages(wiki_root)), + "summaries": _BM25Scorer(_build_summary_pages(wiki_root)), + "sources": _BM25Scorer(_build_source_pages(wiki_root)), + "explorations": _BM25Scorer(_build_exploration_pages(wiki_root)), + } + + def search( + self, query: str, scope: list[str] | None = None, top_k: int = 5 + ) -> dict[str, list[SearchHit]]: + """Search one or more tiers; returns ``{tier_name: [SearchHit, ...]}``. + + Args: + query: Free-text search query (keywords or a question). + scope: Subset of :data:`TIERED_SCOPES` to search; ``None`` + searches all three tiers. + top_k: Maximum ranked results to return per tier. + + Returns: + One entry per searched tier (only the requested/valid tiers are + present as keys — never an empty-list placeholder for tiers the + caller didn't ask for). + + Raises: + ValueError: *scope* contains a name outside :data:`TIERED_SCOPES`. + """ + tiers = scope if scope else list(TIERED_SCOPES) + invalid = [t for t in tiers if t not in TIERED_SCOPES] + if invalid: + raise ValueError(f"Unknown scope(s) {invalid}; expected any of {TIERED_SCOPES}.") + return {tier: self._scorers[tier].search(query, top_k=top_k) for tier in tiers} diff --git a/openkb/mcp_server.py b/openkb/mcp_server.py new file mode 100644 index 000000000..fa537a16d --- /dev/null +++ b/openkb/mcp_server.py @@ -0,0 +1,285 @@ +"""MCP server exposing taxonomy browsing and tiered search to external clients. + +Lets any MCP-capable AI assistant (GitHub Copilot, Claude Code, Cursor, etc.) +browse the wiki's taxonomy, run the tiered BM25 search, and read wiki content +(``agent.content.list_taxonomy_items``/``list_documents``/``get_content``, +``fulltext_index.TieredWikiSearch``) without running inside the ``openkb +query``/``openkb chat`` agent process or shelling out to the CLI. Run with +the ``openkb-mcp`` console script (stdio transport), or ``python -m +openkb.mcp_server``. + +Every tool takes an optional ``kb`` parameter (a registered KB name/alias, +or an absolute path to a KB root) so one long-lived MCP server process can +serve multiple knowledge bases — see ``_resolve_kb`` and ``list_kbs``. +Omitting ``kb`` keeps today's behavior (cwd-walk -> global default), +preserving compatibility with the existing fixed ``"cwd"`` MCP client +config example in README.md. + +No index cache: every tool rebuilds its underlying index fresh on every +call, exactly like the CLI (``openkb list-taxonomy``/``openkb search``) and +the query/chat agent already do (see ``fulltext_index`` module docstring). +This MCP server is typically a longer-lived process than a single CLI +invocation, but OpenKB has no long-running daemon/cache-invalidation concept +today — caching the index across calls here would risk staleness if the KB +changes via a separate ``openkb add`` while this process stays alive, so the +same fresh-per-call rebuild is used deliberately rather than introducing a +new caching model just for this surface. +""" + +from __future__ import annotations + +from pathlib import Path + +from mcp.server.fastmcp import FastMCP + +from openkb.agent.content import ContentEntry, get_content, get_kb_status, list_documents +from openkb.agent.tools import list_taxonomy_items +from openkb.config import load_global_config, registered_kbs, resolve_kb_alias +from openkb.fulltext_index import TieredWikiSearch + +mcp = FastMCP("openkb") + + +def find_kb_dir(start: Path | None = None) -> Path | None: + """Resolve the active KB root: walk up from *start* (default cwd) looking + for ``.openkb/``, else fall back to the global config's ``default_kb``. + + Mirrors ``openkb.cli._find_kb_dir``'s resolution order. Kept as a + separate, lightweight copy here (rather than importing from ``cli.py``) + so starting this MCP server doesn't pull in ``cli.py``'s much heavier + import chain (click, litellm, the Agents SDK) just to resolve a + directory path. + """ + current = (start or Path.cwd()).resolve() + while True: + if (current / ".openkb").is_dir(): + return current + parent = current.parent + if parent == current: + break + current = parent + + gc = load_global_config() + default = gc.get("default_kb") + if default: + candidate = Path(default) + if (candidate / ".openkb").is_dir(): + return candidate + return None + + +def _resolve_kb(kb: str | None) -> Path: + """Resolve *kb* to a KB root directory, for a tool's optional ``kb`` arg. + + - ``None`` (default): today's behavior — :func:`find_kb_dir` (cwd-walk, + then the global default). Fully backward compatible with a fixed + ``"cwd"`` MCP client config (the only way to pick a KB before this). + - An existing directory containing ``.openkb/``: used directly (mirrors + the CLI's ``--kb-dir`` override). + - Otherwise: resolved as a registered KB name/alias via + :func:`openkb.config.resolve_kb_alias` — the same name->path registry + the CLI's ``delete-kb`` and the REST API's ``/api/v1/kbs`` already + use. Call :func:`list_kbs` to discover the available names. + + Raises: + ValueError: *kb* doesn't resolve to a real KB by any of the above, + or (*kb* is ``None`` and) no KB can be found at all. + """ + if kb is None: + kb_dir = find_kb_dir() + if kb_dir is None: + raise ValueError( + "No knowledge base found. Run this from inside a KB directory " + "(or a subdirectory of one), set a default with `openkb use " + "`, or pass an explicit kb=." + ) + return kb_dir + + candidate = Path(kb).expanduser() + if candidate.is_dir() and (candidate / ".openkb").is_dir(): + return candidate.resolve() + + try: + resolved = resolve_kb_alias(kb) + except ValueError: + resolved = None + if resolved is not None and (resolved / ".openkb").is_dir(): + return resolved + + known = ", ".join(name for name, _ in registered_kbs()) or "(none registered)" + raise ValueError( + f"Unknown KB {kb!r}: not an existing KB directory and not a registered " + f"KB name. Known KBs: {known}. Call list_kbs() to discover names." + ) + + +def _wiki_root(kb: str | None = None) -> Path: + """Return the *kb* KB's ``wiki/`` directory (see :func:`_resolve_kb`).""" + return _resolve_kb(kb) / "wiki" + + +@mcp.tool() +def list_kbs() -> list[dict]: + """List every KB this MCP server can address via the ``kb`` parameter. + + Returns: + One dict per registered KB: ``name`` (pass as ``kb=name`` to any + other tool) and ``path`` (its absolute KB root directory). + """ + return [{"name": name, "path": str(path)} for name, path in registered_kbs()] + + +@mcp.tool() +def get_status(kb: str | None = None) -> dict: + """Return the active KB's absolute path and basic content counts. + + Closes the one gap the other tools can't: they return wiki-root-relative + paths (e.g. ``"concepts/attention.md"``), but nothing else reveals the + absolute KB path a client needs to resolve one — call this first if you + don't already know it (mirrors ``openkb status`` for MCP-only clients + with no shell access). + + Args: + kb: Registered KB name/alias or absolute path; omit to use the KB + resolved from the server's cwd or global default (see + :func:`_resolve_kb`). + + Returns: + ``kb_dir`` (absolute path), ``counts`` (``.md`` file count per wiki + subdirectory, plus ``"raw"`` if present), and ``total_indexed`` + (documents in the ``.openkb/hashes.json`` registry). + """ + status = get_kb_status(str(_resolve_kb(kb))) + return {"kb_dir": status.kb_dir, "counts": status.counts, "total_indexed": status.total_indexed} + + +@mcp.tool() +def list_taxonomy(kind: str | None = None, kb: str | None = None) -> list[dict]: + """List persisted concept/entity pages with their one-line briefs. + + Semantic browsing, not keyword search: pick the slug(s) that match the + question's meaning by their brief, then fetch the full page with + ``get_content(slug, kind=...)``. + + Args: + kind: Restrict to "concept" or "entity"; omit for both. + kb: Registered KB name/alias or absolute path; omit to use the KB + resolved from the server's cwd or global default. + + Returns: + One dict per item: ``kind``, ``slug``, ``path``, ``brief``, and + ``type`` (entity type, or ``None`` for concepts). + """ + items = list_taxonomy_items(str(_wiki_root(kb)), kind=kind) + return [ + {"kind": i.kind, "slug": i.slug, "path": i.path, "brief": i.brief, "type": i.type} + for i in items + ] + + +@mcp.tool(name="list_documents") +def list_documents_tool(kind: str | None = None, kb: str | None = None) -> list[dict]: + """List persisted summary/exploration pages with their one-line briefs. + + Args: + kind: Restrict to "summary" or "exploration"; omit for both. + kb: Registered KB name/alias or absolute path; omit to use the KB + resolved from the server's cwd or global default. + + Returns: + One dict per item: ``kind``, ``slug``, ``path``, and ``brief`` + (an exploration's brief is its originally-saved question). + """ + items = list_documents(str(_wiki_root(kb)), kind=kind) + return [{"kind": i.kind, "slug": i.slug, "path": i.path, "brief": i.brief} for i in items] + + +def _content_entry_to_dict(entry: ContentEntry) -> dict: + return {"kind": entry.kind, "path": entry.path, "content": entry.content, "error": entry.error} + + +@mcp.tool(name="get_content") +def get_content_tool( + slug: str, + kind: str | None = None, + pages: str | None = None, + kb: str | None = None, +) -> list[dict]: + """Read wiki content by slug — one tool for every content kind. + + Args: + slug: Page slug (filename without extension), e.g. ``"attention"``. + For "source", identical to the paired summary's slug — a plain + call without ``kind`` commonly returns both as separate entries. + kind: One of "concept", "entity", "summary", "exploration", + "source", "report", "index". Omit to search all seven and get + one entry per match found (0, 1, or several). + pages: Only meaningful for a "source" match — required for a long + (PageIndex) document (e.g. ``"3-5,7"``), forbidden otherwise. + kb: Registered KB name/alias or absolute path; omit to use the KB + resolved from the server's cwd or global default. + + Returns: + One dict per match — always a list, even for a single match: + ``kind``, ``path``, ``content`` (``None`` on error), and ``error`` + (``None`` on success — e.g. a long PageIndex document without + ``pages`` set gets an error explaining what to pass instead of + content). + """ + entries = get_content(slug, str(_wiki_root(kb)), kind=kind, pages=pages) + return [_content_entry_to_dict(e) for e in entries] + + +@mcp.tool() +def search_wiki( + query: str, scope: list[str] | None = None, top_k: int = 5, kb: str | None = None +) -> dict: + """Tiered full-text (BM25) search over summaries/sources/explorations. + + Never covers concepts/entities — use ``list_taxonomy`` for those. Use + this in addition to, not instead of, taxonomy browsing: a hybrid + fallback for a specific buried detail (a niche term, an exact figure, an + author/creation-date only present in a raw source). + + Args: + query: Free-text search query (keywords or a natural-language question). + scope: Restrict to a subset of "briefs" (one-line document + summaries), "summaries" (full document-summary text), "sources" + (raw source files, including per-page indexing of long + PageIndex documents), "explorations" (saved query answers); + omit to search all four. + top_k: Maximum ranked results to return per tier. + kb: Registered KB name/alias or absolute path; omit to use the KB + resolved from the server's cwd or global default. + + Returns: + ``{tier: [hit, ...]}`` for each searched tier. Each hit has + ``path``, ``title``, ``score``, ``snippet``, and ``locator`` + (``{"kind": "line"|"page", "value": int}`` or ``None``) — a "page" + locator names the exact PageIndex page to fetch for that document. + """ + results = TieredWikiSearch(str(_wiki_root(kb))).search(query, scope=scope, top_k=top_k) + return { + tier: [ + { + "path": hit.path, + "title": hit.title, + "score": hit.score, + "snippet": hit.snippet, + "locator": ( + {"kind": hit.locator.kind, "value": hit.locator.value} if hit.locator else None + ), + } + for hit in hits + ] + for tier, hits in results.items() + } + + +def main() -> None: + """Entry point for the ``openkb-mcp`` console script (stdio transport).""" + mcp.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 389a8b2b8..a712999e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,14 @@ dependencies = [ "prompt_toolkit==3.0.52", "rich==15.0.0", "portalocker==3.2.0", + # Already resolved transitively via openai-agents (MCP Python SDK v2 + # client support: MCPServerStdio/MCPServerStreamableHttp, see PR #207). + # Pinned explicitly here too because openkb.mcp_server now imports + # mcp.server.fastmcp directly (server-side, not just the client support + # openai-agents needs) — an explicit top-level pin makes that dependency + # intentional rather than an implicit side effect of another package's + # requirements. + "mcp==1.27.1", ] [project.urls] @@ -62,6 +70,7 @@ openkb = "openkb.cli:cli" openkb-web = "openkb.api:main" # Backwards-compatible alias for the historical name; same entry point. openkb-api = "openkb.api:main" +openkb-mcp = "openkb.mcp_server:main" [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/test_agent_tools.py b/tests/test_agent_tools.py index 283a5a8b0..b5b588bba 100644 --- a/tests/test_agent_tools.py +++ b/tests/test_agent_tools.py @@ -3,12 +3,19 @@ from __future__ import annotations from openkb.agent.tools import ( + DocumentItem, + TaxonomyItem, artifact_event_from_write, + get_content, + get_kb_status, get_wiki_page_content, + list_documents, + list_taxonomy_items, list_wiki_files, parse_pages, read_wiki_file, read_wiki_image, + search_wiki, write_wiki_file, ) @@ -139,6 +146,35 @@ def test_path_is_relative_to_wiki_root(self, tmp_path): assert "Summary content." in result + def test_reads_index_md(self, tmp_path): + # index.md and reports/ are the two cases get_content added the + # "index"/"report" kinds for, so read_wiki_file has no remaining + # raw-path fallback case for them. + wiki_root = str(tmp_path) + (tmp_path / "index.md").write_text("# KB Index\n") + + result = read_wiki_file("index.md", wiki_root) + + assert "# KB Index" in result + + def test_reads_report_file(self, tmp_path): + wiki_root = str(tmp_path) + (tmp_path / "reports").mkdir() + (tmp_path / "reports" / "health.md").write_text("All good.") + + result = read_wiki_file("reports/health.md", wiki_root) + + assert result == "All good." + + def test_path_traversal_denied_via_fallback(self, tmp_path): + # Doesn't map to any of get_content's known directories -> falls + # through to the defensive raw-path fallback, which still rejects it. + wiki_root = str(tmp_path) + + result = read_wiki_file("../../etc/passwd", wiki_root) + + assert "denied" in result.lower() + # --------------------------------------------------------------------------- # write_wiki_file @@ -320,3 +356,388 @@ def test_artifact_event_none_for_non_output_zone(): def test_artifact_event_none_for_bad_json(): assert artifact_event_from_write("write_file", "not json", "Written: output/x.html") is None + + +# --------------------------------------------------------------------------- +# search_wiki +# --------------------------------------------------------------------------- + + +class TestSearchWiki: + def test_finds_matching_page(self, tmp_path): + wiki_root = str(tmp_path) + (tmp_path / "concepts").mkdir() + (tmp_path / "concepts" / "cnn.md").write_text( + "# Convolutional Neural Networks\n\nDropout regularization prevents overfitting." + ) + + result = search_wiki("dropout regularization", wiki_root) + + assert "[[concepts/cnn]]" in result + assert "Convolutional Neural Networks" in result + + def test_no_matches_returns_message(self, tmp_path): + wiki_root = str(tmp_path) + (tmp_path / "concepts").mkdir() + (tmp_path / "concepts" / "cnn.md").write_text("# CNN\n\nSomething else entirely.") + + result = search_wiki("nonexistent_keyword_xyz", wiki_root) + + assert result == "No matching pages found." + + def test_respects_top_k(self, tmp_path): + wiki_root = str(tmp_path) + (tmp_path / "entities").mkdir() + for i in range(5): + (tmp_path / "entities" / f"e{i}.md").write_text(f"# Entity {i}\n\nkeyword {i}.") + + result = search_wiki("keyword", wiki_root, top_k=2) + + assert result.count("[[entities/") == 2 + + +# --------------------------------------------------------------------------- +# list_taxonomy_items +# --------------------------------------------------------------------------- + + +class TestListTaxonomyItems: + def test_lists_concepts_and_entities_by_default(self, tmp_path): + (tmp_path / "concepts").mkdir() + (tmp_path / "concepts" / "attention.md").write_text( + '---\ndescription: "How attention works"\n---\n\n# Attention\n\nBody.' + ) + (tmp_path / "entities").mkdir() + (tmp_path / "entities" / "acme.md").write_text( + '---\ntype: organization\ndescription: "A company"\n---\n\n# Acme\n\nBody.' + ) + + items = list_taxonomy_items(str(tmp_path)) + + assert len(items) == 2 + by_slug = {i.slug: i for i in items} + assert by_slug["attention"].kind == "concept" + assert by_slug["attention"].brief == "How attention works" + assert by_slug["attention"].type is None + assert by_slug["acme"].kind == "entity" + assert by_slug["acme"].type == "organization" + assert by_slug["acme"].brief == "A company" + + def test_kind_filter_restricts_to_one_directory(self, tmp_path): + (tmp_path / "concepts").mkdir() + (tmp_path / "concepts" / "c.md").write_text("# C\n\nBody.") + (tmp_path / "entities").mkdir() + (tmp_path / "entities" / "e.md").write_text("# E\n\nBody.") + + items = list_taxonomy_items(str(tmp_path), kind="concept") + + assert len(items) == 1 + assert items[0].kind == "concept" + + def test_legacy_brief_key_resolves(self, tmp_path): + (tmp_path / "concepts").mkdir() + (tmp_path / "concepts" / "c.md").write_text('---\nbrief: "legacy brief"\n---\n\n# C\n\nX.') + + items = list_taxonomy_items(str(tmp_path)) + + assert items[0].brief == "legacy brief" + + def test_missing_directories_return_empty_list(self, tmp_path): + assert list_taxonomy_items(str(tmp_path)) == [] + + def test_no_frontmatter_yields_empty_brief(self, tmp_path): + (tmp_path / "concepts").mkdir() + (tmp_path / "concepts" / "c.md").write_text("# C\n\nNo frontmatter here.") + + items = list_taxonomy_items(str(tmp_path)) + + assert items[0].brief == "" + + def test_invalid_kind_raises_value_error(self, tmp_path): + import pytest + + with pytest.raises(ValueError, match="Unknown kind"): + list_taxonomy_items(str(tmp_path), kind="document") + + def test_items_are_taxonomy_item_instances(self, tmp_path): + (tmp_path / "concepts").mkdir() + (tmp_path / "concepts" / "c.md").write_text("# C\n\nBody.") + + items = list_taxonomy_items(str(tmp_path)) + + assert isinstance(items[0], TaxonomyItem) + + +# --------------------------------------------------------------------------- +# list_documents +# --------------------------------------------------------------------------- + + +class TestListDocuments: + def test_lists_summaries_and_explorations_by_default(self, tmp_path): + (tmp_path / "summaries").mkdir() + (tmp_path / "summaries" / "paper.md").write_text( + '---\ndescription: "A paper about attention"\n---\n\nBody.' + ) + (tmp_path / "explorations").mkdir() + (tmp_path / "explorations" / "q1.md").write_text( + '---\nquery: "What is attention?"\n---\n\nAnswer body.' + ) + + items = list_documents(str(tmp_path)) + + assert len(items) == 2 + by_slug = {i.slug: i for i in items} + assert by_slug["paper"].kind == "summary" + assert by_slug["paper"].brief == "A paper about attention" + assert by_slug["q1"].kind == "exploration" + assert by_slug["q1"].brief == "What is attention?" + + def test_kind_filter_restricts_to_one_directory(self, tmp_path): + (tmp_path / "summaries").mkdir() + (tmp_path / "summaries" / "s.md").write_text("Body.") + (tmp_path / "explorations").mkdir() + (tmp_path / "explorations" / "e.md").write_text("Body.") + + items = list_documents(str(tmp_path), kind="summary") + + assert len(items) == 1 + assert items[0].kind == "summary" + + def test_missing_directories_return_empty_list(self, tmp_path): + assert list_documents(str(tmp_path)) == [] + + def test_exploration_without_query_field_yields_empty_brief(self, tmp_path): + (tmp_path / "explorations").mkdir() + (tmp_path / "explorations" / "e.md").write_text("No frontmatter here.") + + items = list_documents(str(tmp_path), kind="exploration") + + assert items[0].brief == "" + + def test_invalid_kind_raises_value_error(self, tmp_path): + import pytest + + with pytest.raises(ValueError, match="Unknown kind"): + list_documents(str(tmp_path), kind="concept") + + def test_items_are_document_item_instances(self, tmp_path): + (tmp_path / "summaries").mkdir() + (tmp_path / "summaries" / "s.md").write_text("Body.") + + items = list_documents(str(tmp_path)) + + assert isinstance(items[0], DocumentItem) + + +# --------------------------------------------------------------------------- +# get_content +# --------------------------------------------------------------------------- + + +class TestGetContent: + def test_reads_concept_page(self, tmp_path): + (tmp_path / "concepts").mkdir() + (tmp_path / "concepts" / "attention.md").write_text("# Attention\n\nFull content here.") + + result = get_content("attention", str(tmp_path), kind="concept") + + assert len(result) == 1 + assert result[0].error is None + assert "Full content here." in result[0].content + + def test_kind_disambiguates_same_slug(self, tmp_path): + (tmp_path / "concepts").mkdir() + (tmp_path / "concepts" / "acme.md").write_text("# Acme concept") + (tmp_path / "entities").mkdir() + (tmp_path / "entities" / "acme.md").write_text("# Acme entity") + + concept = get_content("acme", str(tmp_path), kind="concept")[0] + entity = get_content("acme", str(tmp_path), kind="entity")[0] + + assert "concept" in concept.content + assert "entity" in entity.content + + def test_without_kind_returns_one_entry_per_match_not_first_wins(self, tmp_path): + # Summary and source commonly share the same slug (same document) — + # kind=None must surface BOTH, not silently drop one via precedence. + (tmp_path / "summaries").mkdir() + (tmp_path / "summaries" / "paper.md").write_text("Summary body.") + (tmp_path / "sources").mkdir() + (tmp_path / "sources" / "paper.md").write_text("Source body.") + + results = get_content("paper", str(tmp_path)) + + assert len(results) == 2 + kinds = {r.kind for r in results} + assert kinds == {"summary", "source"} + + def test_single_match_is_still_a_list(self, tmp_path): + (tmp_path / "concepts").mkdir() + (tmp_path / "concepts" / "solo.md").write_text("Only one match.") + + results = get_content("solo", str(tmp_path)) + + assert isinstance(results, list) + assert len(results) == 1 + + def test_no_match_returns_empty_list_when_kind_not_given(self, tmp_path): + assert get_content("nonexistent", str(tmp_path)) == [] + + def test_explicit_kind_not_found_returns_error_entry(self, tmp_path): + result = get_content("nonexistent", str(tmp_path), kind="concept") + + assert len(result) == 1 + assert result[0].content is None + assert "not found" in result[0].error.lower() + + def test_invalid_kind_raises_value_error(self, tmp_path): + import pytest + + with pytest.raises(ValueError, match="Unknown kind"): + get_content("slug", str(tmp_path), kind="document") + + def test_path_traversal_is_rejected(self, tmp_path): + result = get_content("../../etc/passwd", str(tmp_path), kind="concept")[0] + + assert "denied" in result.error.lower() + + def test_reads_index_page(self, tmp_path): + (tmp_path / "index.md").write_text("# Knowledge Base Index\n") + + result = get_content("index", str(tmp_path), kind="index")[0] + + assert result.error is None + assert "Knowledge Base Index" in result.content + + def test_reads_report_page(self, tmp_path): + (tmp_path / "reports").mkdir() + (tmp_path / "reports" / "health.md").write_text("All good.") + + result = get_content("health", str(tmp_path), kind="report")[0] + + assert result.content == "All good." + + def test_source_short_doc_reads_whole_file(self, tmp_path): + (tmp_path / "sources").mkdir() + (tmp_path / "sources" / "notes.md").write_text("Notes body.") + + result = get_content("notes", str(tmp_path), kind="source")[0] + + assert result.content == "Notes body." + + def test_source_short_doc_rejects_pages(self, tmp_path): + (tmp_path / "sources").mkdir() + (tmp_path / "sources" / "notes.md").write_text("Notes body.") + + result = get_content("notes", str(tmp_path), kind="source", pages="1")[0] + + assert result.content is None + assert "pages" in result.error.lower() + + def test_source_long_doc_requires_pages(self, tmp_path): + import json + + (tmp_path / "sources").mkdir() + (tmp_path / "sources" / "paper.json").write_text( + json.dumps([{"page": 1, "content": "Page one."}]), encoding="utf-8" + ) + + result = get_content("paper", str(tmp_path), kind="source")[0] + + assert result.content is None + assert "pages" in result.error.lower() + + def test_source_long_doc_with_pages_returns_content(self, tmp_path): + import json + + (tmp_path / "sources").mkdir() + (tmp_path / "sources" / "paper.json").write_text( + json.dumps([{"page": 1, "content": "Page one."}, {"page": 2, "content": "Page two."}]), + encoding="utf-8", + ) + + result = get_content("paper", str(tmp_path), kind="source", pages="2")[0] + + assert result.error is None + assert "Page two." in result.content + assert "Page one." not in result.content + + def test_explicit_non_source_kind_with_pages_errors(self, tmp_path): + (tmp_path / "concepts").mkdir() + (tmp_path / "concepts" / "c.md").write_text("Concept body.") + + result = get_content("c", str(tmp_path), kind="concept", pages="1")[0] + + assert result.content is None + assert "pages" in result.error.lower() + + def test_pages_silently_ignored_during_kind_none_fan_out(self, tmp_path): + # Setting `pages` while fanning out across all kinds (kind=None) must + # not error out a match that has nothing to do with pagination — it's + # only meaningful for a "source" match, and even then only when that + # source turns out to be a long PageIndex document. + (tmp_path / "concepts").mkdir() + (tmp_path / "concepts" / "c.md").write_text("Concept body.") + + results = get_content("c", str(tmp_path), pages="1") + + assert len(results) == 1 + assert results[0].error is None + assert results[0].content == "Concept body." + + +# --------------------------------------------------------------------------- +# get_kb_status +# --------------------------------------------------------------------------- + + +class TestGetKbStatus: + def test_counts_md_files_per_subdir(self, tmp_path): + (tmp_path / "wiki" / "concepts").mkdir(parents=True) + (tmp_path / "wiki" / "concepts" / "a.md").write_text("A") + (tmp_path / "wiki" / "concepts" / "b.md").write_text("B") + (tmp_path / "wiki" / "summaries").mkdir() + (tmp_path / "wiki" / "summaries" / "s.md").write_text("S") + + status = get_kb_status(str(tmp_path)) + + assert status.counts["concepts"] == 2 + assert status.counts["summaries"] == 1 + assert status.counts["entities"] == 0 + + def test_kb_dir_is_absolute(self, tmp_path): + status = get_kb_status(str(tmp_path)) + + assert status.kb_dir == str(tmp_path.resolve()) + + def test_no_registry_yields_zero_total_indexed(self, tmp_path): + status = get_kb_status(str(tmp_path)) + + assert status.total_indexed == 0 + + def test_reads_total_indexed_from_hashes_registry(self, tmp_path): + import json + + (tmp_path / ".openkb").mkdir() + (tmp_path / ".openkb" / "hashes.json").write_text( + json.dumps({"hash1": {"name": "a.pdf"}, "hash2": {"name": "b.pdf"}}) + ) + + status = get_kb_status(str(tmp_path)) + + assert status.total_indexed == 2 + + def test_counts_raw_files_when_raw_dir_exists(self, tmp_path): + (tmp_path / "raw").mkdir() + (tmp_path / "raw" / "doc.pdf").write_text("x") + (tmp_path / "raw" / "doc2.pdf").write_text("x") + + status = get_kb_status(str(tmp_path)) + + assert status.counts["raw"] == 2 + + def test_no_raw_dir_omits_raw_count(self, tmp_path): + status = get_kb_status(str(tmp_path)) + + assert "raw" not in status.counts diff --git a/tests/test_fulltext_index.py b/tests/test_fulltext_index.py new file mode 100644 index 000000000..e79636dba --- /dev/null +++ b/tests/test_fulltext_index.py @@ -0,0 +1,283 @@ +"""Tests for openkb.fulltext_index (BM25 hybrid search).""" + +from __future__ import annotations + +import json + +from openkb.fulltext_index import Locator, TieredWikiSearch, WikiFullTextIndex + + +def _write(tmp_path, subdir, name, text): + directory = tmp_path / subdir + directory.mkdir(parents=True, exist_ok=True) + (directory / name).write_text(text, encoding="utf-8") + + +class TestWikiFullTextIndex: + def test_empty_wiki_returns_no_hits(self, tmp_path): + index = WikiFullTextIndex(str(tmp_path)) + assert index.search("anything") == [] + + def test_finds_page_by_keyword_in_body(self, tmp_path): + _write( + tmp_path, + "concepts", + "cnn.md", + "# Convolutional Neural Networks\n\nAlexNet popularized ReLU activations " + "and dropout regularization for large-scale image classification.", + ) + _write( + tmp_path, + "concepts", + "unrelated.md", + "# Gardening\n\nTomatoes need plenty of sunlight and water.", + ) + + hits = WikiFullTextIndex(str(tmp_path)).search("dropout regularization") + + assert len(hits) == 1 + assert hits[0].path == "concepts/cnn.md" + assert hits[0].title == "Convolutional Neural Networks" + assert hits[0].score > 0 + + def test_ranks_more_relevant_page_higher(self, tmp_path): + _write( + tmp_path, + "concepts", + "on-topic.md", + "# Topic\n\nAlexNet AlexNet AlexNet training data criticism bias bias.", + ) + _write( + tmp_path, + "concepts", + "off-topic.md", + "# Other\n\nA single passing mention of AlexNet in an unrelated paragraph " + "about something else entirely, padded with filler words to change length.", + ) + + hits = WikiFullTextIndex(str(tmp_path)).search("AlexNet bias") + + assert [hit.path for hit in hits[:1]] == ["concepts/on-topic.md"] + + def test_respects_top_k(self, tmp_path): + for i in range(10): + _write(tmp_path, "entities", f"e{i}.md", f"# Entity {i}\n\nkeyword appears here {i}.") + + hits = WikiFullTextIndex(str(tmp_path)).search("keyword", top_k=3) + + assert len(hits) == 3 + + def test_only_indexes_page_content_dirs(self, tmp_path): + _write(tmp_path, "sources", "raw.md", "# Raw\n\nkeyword raw source content.") + _write(tmp_path, "concepts", "c.md", "# Concept\n\nkeyword concept content.") + + hits = WikiFullTextIndex(str(tmp_path)).search("keyword") + + assert [hit.path for hit in hits] == ["concepts/c.md"] + + def test_falls_back_to_filename_when_no_heading(self, tmp_path): + _write(tmp_path, "summaries", "no-heading.md", "keyword content without a heading line.") + + hits = WikiFullTextIndex(str(tmp_path)).search("keyword") + + assert hits[0].title == "no-heading" + + def test_no_query_tokens_returns_no_hits(self, tmp_path): + _write(tmp_path, "concepts", "c.md", "# Concept\n\nkeyword concept content.") + + hits = WikiFullTextIndex(str(tmp_path)).search(" ") + + assert hits == [] + + def test_snippet_contains_context_around_match(self, tmp_path): + _write( + tmp_path, + "concepts", + "c.md", + "# Concept\n\n" + + ("padding " * 40) + + "the exact fee is five hundred dollars" + + (" more" * 40), + ) + + hits = WikiFullTextIndex(str(tmp_path)).search("fee") + + assert "fee" in hits[0].snippet.lower() + + +class TestTieredWikiSearchBriefs: + def test_matches_brief_frontmatter_not_body(self, tmp_path): + _write( + tmp_path, + "summaries", + "doc-a.md", + '---\ndescription: "Salesforce Case Management overview"\n---\n\n' + "# Doc A\n\nUnrelated body text about something else entirely.", + ) + + result = TieredWikiSearch(str(tmp_path)).search("case management", scope=["briefs"]) + + assert len(result["briefs"]) == 1 + assert result["briefs"][0].path == "summaries/doc-a.md" + + def test_legacy_brief_key_still_resolves(self, tmp_path): + _write( + tmp_path, + "summaries", + "doc-a.md", + '---\nbrief: "legacy field name lookup notes"\n---\n\n# Doc A\n\nBody.', + ) + + result = TieredWikiSearch(str(tmp_path)).search("field name lookup", scope=["briefs"]) + + assert len(result["briefs"]) == 1 + + def test_no_brief_frontmatter_yields_no_hit(self, tmp_path): + _write(tmp_path, "summaries", "doc-a.md", "# Doc A\n\nkeyword body text, no frontmatter.") + + result = TieredWikiSearch(str(tmp_path)).search("keyword", scope=["briefs"]) + + assert result["briefs"] == [] + + +class TestTieredWikiSearchSummaries: + def test_matches_full_body_not_just_brief(self, tmp_path): + _write( + tmp_path, + "summaries", + "doc-a.md", + '---\ndescription: "General overview"\n---\n\n' + "# Doc A\n\nDetails about custom_field_xyz appear only here.", + ) + + result = TieredWikiSearch(str(tmp_path)).search("custom_field_xyz", scope=["summaries"]) + + assert len(result["summaries"]) == 1 + assert result["summaries"][0].locator is not None + assert result["summaries"][0].locator.kind == "line" + + def test_frontmatter_block_itself_is_not_indexed(self, tmp_path): + _write( + tmp_path, + "summaries", + "doc-a.md", + '---\ndescription: "uniquefrontmatterterm should not match body search"\n---\n\n' + "# Doc A\n\nUnrelated body.", + ) + + result = TieredWikiSearch(str(tmp_path)).search( + "uniquefrontmatterterm", scope=["summaries"] + ) + + assert result["summaries"] == [] + + +class TestTieredWikiSearchSources: + def test_short_source_doc_gets_line_locator(self, tmp_path): + _write( + tmp_path, + "sources", + "notes.md", + "Line one.\nLine two.\nAuthor: Jane Doe, created 2024-03-15.\nLine four.", + ) + + result = TieredWikiSearch(str(tmp_path)).search("Jane Doe", scope=["sources"]) + + assert len(result["sources"]) == 1 + hit = result["sources"][0] + assert hit.path == "sources/notes.md" + assert hit.locator == Locator(kind="line", value=3) + + def test_pageindex_json_hit_gets_page_locator_not_whole_document(self, tmp_path): + pages = [ + {"page": 1, "content": "Introduction, nothing special here."}, + {"page": 2, "content": "The field_xyz default value is 42."}, + {"page": 3, "content": "Conclusion, also nothing special."}, + ] + sources_dir = tmp_path / "sources" + sources_dir.mkdir(parents=True) + (sources_dir / "long-doc.json").write_text(json.dumps(pages), encoding="utf-8") + + result = TieredWikiSearch(str(tmp_path)).search("field_xyz", scope=["sources"]) + + assert len(result["sources"]) == 1 + hit = result["sources"][0] + assert hit.path == "sources/long-doc.json" + assert hit.locator == Locator(kind="page", value=2) + + def test_malformed_json_source_is_skipped_not_raised(self, tmp_path): + sources_dir = tmp_path / "sources" + sources_dir.mkdir(parents=True) + (sources_dir / "broken.json").write_text("{not valid json", encoding="utf-8") + + result = TieredWikiSearch(str(tmp_path)).search("anything", scope=["sources"]) + + assert result["sources"] == [] + + +class TestTieredWikiSearchScope: + def test_default_scope_searches_all_four_tiers(self, tmp_path): + _write( + tmp_path, + "summaries", + "doc.md", + '---\ndescription: "keyword brief"\n---\n\n# Doc\n\nkeyword body.', + ) + _write(tmp_path, "sources", "doc.md", "keyword raw source.") + _write( + tmp_path, + "explorations", + "q1.md", + '---\nquery: "keyword question"\n---\n\nkeyword answer body.', + ) + + result = TieredWikiSearch(str(tmp_path)).search("keyword") + + assert set(result.keys()) == {"briefs", "summaries", "sources", "explorations"} + assert len(result["briefs"]) == 1 + assert len(result["summaries"]) == 1 + assert len(result["sources"]) == 1 + assert len(result["explorations"]) == 1 + + def test_concepts_and_entities_are_never_searched(self, tmp_path): + _write(tmp_path, "concepts", "c.md", "# Concept\n\nkeyword concept content.") + _write(tmp_path, "entities", "e.md", "# Entity\n\nkeyword entity content.") + + result = TieredWikiSearch(str(tmp_path)).search("keyword") + + assert result["briefs"] == [] + assert result["summaries"] == [] + assert result["sources"] == [] + assert result["explorations"] == [] + + def test_explorations_is_its_own_tier_not_merged_with_summaries(self, tmp_path): + _write( + tmp_path, + "summaries", + "doc.md", + '---\ndescription: "keyword brief"\n---\n\n# Doc\n\nkeyword body.', + ) + _write( + tmp_path, + "explorations", + "q1.md", + '---\nquery: "keyword question"\n---\n\nkeyword answer body.', + ) + + result = TieredWikiSearch(str(tmp_path)).search("keyword", scope=["explorations"]) + + assert set(result.keys()) == {"explorations"} + assert len(result["explorations"]) == 1 + assert result["explorations"][0].path == "explorations/q1.md" + assert result["explorations"][0].title == "keyword question" + + def test_invalid_scope_raises_value_error(self, tmp_path): + import pytest + + with pytest.raises(ValueError, match="Unknown scope"): + TieredWikiSearch(str(tmp_path)).search("keyword", scope=["not-a-real-tier"]) + + def test_empty_wiki_returns_empty_lists_for_all_tiers(self, tmp_path): + result = TieredWikiSearch(str(tmp_path)).search("anything") + + assert result == {"briefs": [], "summaries": [], "sources": [], "explorations": []} diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 000000000..540bbaea6 --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,279 @@ +"""Tests for openkb.mcp_server (MCP tools: list_taxonomy, search_wiki, etc.).""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from openkb.mcp_server import ( + _resolve_kb, + find_kb_dir, + get_content_tool, + get_status, + list_documents_tool, + list_kbs, + list_taxonomy, + search_wiki, +) + + +def _make_kb(tmp_path): + """Create a minimal KB (``.openkb/`` marker + a few wiki pages).""" + tmp_path.mkdir(parents=True, exist_ok=True) + (tmp_path / ".openkb").mkdir() + (tmp_path / "wiki" / "concepts").mkdir(parents=True) + (tmp_path / "wiki" / "summaries").mkdir(parents=True) + (tmp_path / "wiki" / "sources").mkdir(parents=True) + (tmp_path / "wiki" / "concepts" / "attention.md").write_text( + '---\ndescription: "How attention works"\n---\n\n# Attention\n\nBody.', + encoding="utf-8", + ) + (tmp_path / "wiki" / "summaries" / "doc.md").write_text( + '---\ndescription: "Overview"\n---\n\n# Doc\n\nDetails about field_xyz appear here.', + encoding="utf-8", + ) + return tmp_path + + +class TestFindKbDir: + def test_finds_kb_at_cwd(self, tmp_path, monkeypatch): + _make_kb(tmp_path) + monkeypatch.chdir(tmp_path) + + assert find_kb_dir() == tmp_path.resolve() + + def test_finds_kb_by_walking_up_from_subdirectory(self, tmp_path, monkeypatch): + _make_kb(tmp_path) + subdir = tmp_path / "a" / "b" + subdir.mkdir(parents=True) + monkeypatch.chdir(subdir) + + assert find_kb_dir() == tmp_path.resolve() + + def test_falls_back_to_global_default_kb(self, tmp_path, monkeypatch): + no_kb_cwd = tmp_path / "elsewhere" + no_kb_cwd.mkdir() + kb_dir = _make_kb(tmp_path / "the-kb") + monkeypatch.chdir(no_kb_cwd) + + with patch( + "openkb.mcp_server.load_global_config", + return_value={"default_kb": str(kb_dir)}, + ): + assert find_kb_dir() == kb_dir.resolve() + + def test_returns_none_when_no_kb_found_anywhere(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + with patch("openkb.mcp_server.load_global_config", return_value={}): + assert find_kb_dir() is None + + +class TestMcpListTaxonomy: + def test_lists_items_as_plain_dicts(self, tmp_path, monkeypatch): + _make_kb(tmp_path) + monkeypatch.chdir(tmp_path) + + result = list_taxonomy() + + assert result == [ + { + "kind": "concept", + "slug": "attention", + "path": "concepts/attention.md", + "brief": "How attention works", + "type": None, + } + ] + + def test_no_kb_raises_clear_error(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + with patch("openkb.mcp_server.load_global_config", return_value={}): + with pytest.raises(ValueError, match="No knowledge base found"): + list_taxonomy() + + +class TestMcpSearchWiki: + def test_finds_hit_in_summaries_tier(self, tmp_path, monkeypatch): + _make_kb(tmp_path) + monkeypatch.chdir(tmp_path) + + result = search_wiki("field_xyz") + + assert result["briefs"] == [] + assert len(result["summaries"]) == 1 + assert result["summaries"][0]["path"] == "summaries/doc.md" + assert result["summaries"][0]["locator"] == {"kind": "line", "value": 4} + assert result["sources"] == [] + assert result["explorations"] == [] + + def test_scope_restricts_tiers(self, tmp_path, monkeypatch): + _make_kb(tmp_path) + monkeypatch.chdir(tmp_path) + + result = search_wiki("field_xyz", scope=["briefs"]) + + assert set(result.keys()) == {"briefs"} + + def test_invalid_scope_raises(self, tmp_path, monkeypatch): + _make_kb(tmp_path) + monkeypatch.chdir(tmp_path) + + with pytest.raises(ValueError, match="Unknown scope"): + search_wiki("field_xyz", scope=["not-a-tier"]) + + +# --------------------------------------------------------------------------- +# _resolve_kb / multi-vault +# --------------------------------------------------------------------------- + + +class TestResolveKb: + def test_none_falls_back_to_find_kb_dir(self, tmp_path, monkeypatch): + _make_kb(tmp_path) + monkeypatch.chdir(tmp_path) + + assert _resolve_kb(None) == tmp_path.resolve() + + def test_none_raises_when_no_kb_found(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + with patch("openkb.mcp_server.load_global_config", return_value={}): + with pytest.raises(ValueError, match="No knowledge base found"): + _resolve_kb(None) + + def test_explicit_path_used_directly(self, tmp_path, monkeypatch): + # cwd is a different, unrelated directory - only an explicit `kb` + # path should be used, not cwd-walk. + other_cwd = tmp_path / "elsewhere" + other_cwd.mkdir() + kb_dir = _make_kb(tmp_path / "the-kb") + monkeypatch.chdir(other_cwd) + + assert _resolve_kb(str(kb_dir)) == kb_dir.resolve() + + def test_registered_name_resolved_via_config(self, tmp_path, monkeypatch): + kb_dir = _make_kb(tmp_path / "my-kb") + monkeypatch.chdir(tmp_path) + + with patch("openkb.mcp_server.resolve_kb_alias", return_value=kb_dir): + assert _resolve_kb("my-kb") == kb_dir + + def test_unknown_name_raises_with_known_kbs_listed(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + with patch("openkb.mcp_server.resolve_kb_alias", side_effect=ValueError("bad name")): + with patch( + "openkb.mcp_server.registered_kbs", return_value=[("alpha", tmp_path / "alpha")] + ): + with pytest.raises(ValueError, match="alpha"): + _resolve_kb("nonexistent") + + +# --------------------------------------------------------------------------- +# list_kbs +# --------------------------------------------------------------------------- + + +class TestMcpListKbs: + def test_returns_registered_kbs_as_dicts(self, tmp_path): + with patch( + "openkb.mcp_server.registered_kbs", + return_value=[("alpha", tmp_path / "alpha"), ("beta", tmp_path / "beta")], + ): + result = list_kbs() + + assert result == [ + {"name": "alpha", "path": str(tmp_path / "alpha")}, + {"name": "beta", "path": str(tmp_path / "beta")}, + ] + + +# --------------------------------------------------------------------------- +# get_status +# --------------------------------------------------------------------------- + + +class TestMcpGetStatus: + def test_returns_kb_dir_and_counts(self, tmp_path, monkeypatch): + _make_kb(tmp_path) + monkeypatch.chdir(tmp_path) + + result = get_status() + + assert result["kb_dir"] == str(tmp_path.resolve()) + assert result["counts"]["concepts"] == 1 + assert result["counts"]["summaries"] == 1 + assert result["total_indexed"] == 0 + + def test_explicit_kb_path_used(self, tmp_path, monkeypatch): + other_cwd = tmp_path / "elsewhere" + other_cwd.mkdir() + kb_dir = _make_kb(tmp_path / "the-kb") + monkeypatch.chdir(other_cwd) + + result = get_status(kb=str(kb_dir)) + + assert result["kb_dir"] == str(kb_dir.resolve()) + + +# --------------------------------------------------------------------------- +# list_documents (MCP tool) +# --------------------------------------------------------------------------- + + +class TestMcpListDocuments: + def test_lists_summaries_as_plain_dicts(self, tmp_path, monkeypatch): + _make_kb(tmp_path) + monkeypatch.chdir(tmp_path) + + result = list_documents_tool() + + assert result == [ + {"kind": "summary", "slug": "doc", "path": "summaries/doc.md", "brief": "Overview"} + ] + + def test_kind_filter(self, tmp_path, monkeypatch): + _make_kb(tmp_path) + monkeypatch.chdir(tmp_path) + + assert list_documents_tool(kind="exploration") == [] + + +# --------------------------------------------------------------------------- +# get_content (MCP tool) +# --------------------------------------------------------------------------- + + +class TestMcpGetContent: + def test_reads_a_concept_page(self, tmp_path, monkeypatch): + _make_kb(tmp_path) + monkeypatch.chdir(tmp_path) + + result = get_content_tool("attention", kind="concept") + + assert len(result) == 1 + assert result[0]["error"] is None + assert "Body." in result[0]["content"] + + def test_returns_error_entry_for_missing_slug(self, tmp_path, monkeypatch): + _make_kb(tmp_path) + monkeypatch.chdir(tmp_path) + + result = get_content_tool("nonexistent", kind="concept") + + assert len(result) == 1 + assert result[0]["content"] is None + assert "not found" in result[0]["error"].lower() + + def test_kb_param_selects_explicit_kb(self, tmp_path, monkeypatch): + other_cwd = tmp_path / "elsewhere" + other_cwd.mkdir() + kb_dir = _make_kb(tmp_path / "the-kb") + monkeypatch.chdir(other_cwd) + + result = get_content_tool("attention", kind="concept", kb=str(kb_dir)) + + assert result[0]["error"] is None diff --git a/tests/test_query.py b/tests/test_query.py index ecaceabd9..a720ccce3 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -19,13 +19,14 @@ def test_agent_name(self, tmp_path): def test_agent_has_three_tools(self, tmp_path): agent = build_query_agent(str(tmp_path), "gpt-4o-mini") - assert len(agent.tools) == 3 + assert len(agent.tools) == 4 def test_agent_tool_names(self, tmp_path): agent = build_query_agent(str(tmp_path), "gpt-4o-mini") names = {t.name for t in agent.tools} assert "read_file" in names assert "get_page_content" in names + assert "search_wiki" in names assert "get_image" in names def test_instructions_mention_get_page_content(self, tmp_path): diff --git a/uv.lock b/uv.lock index b9c12c731..72b2fcb0d 100644 --- a/uv.lock +++ b/uv.lock @@ -596,7 +596,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1963,6 +1963,7 @@ dependencies = [ { name = "json-repair" }, { name = "litellm" }, { name = "markitdown", extra = ["docx", "pptx", "xls", "xlsx"] }, + { name = "mcp" }, { name = "openai" }, { name = "openai-agents" }, { name = "pageindex" }, @@ -2004,6 +2005,7 @@ requires-dist = [ { name = "json-repair", specifier = "==0.59.10" }, { name = "litellm", specifier = "==1.87.2" }, { name = "markitdown", extras = ["docx", "pptx", "xls", "xlsx"], specifier = "==0.1.5" }, + { name = "mcp", specifier = "==1.27.1" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.15.0" }, { name = "openai", specifier = "==2.44.0" }, { name = "openai-agents", specifier = "==0.17.3" }, @@ -2078,10 +2080,10 @@ resolution-markers = [ "python_full_version < '3.11' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "python-dateutil", marker = "python_full_version < '3.11'" }, - { name = "pytz", marker = "python_full_version < '3.11'" }, - { name = "tzdata", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -2149,9 +2151,9 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, - { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } wheels = [