Skip to content

Commit 0025ea0

Browse files
author
Sebastian Braun
committed
feat(search): add tiered BM25 search (briefs/summaries/sources) and taxonomy accessors
- fulltext_index.py: extract shared _BM25Scorer from WikiFullTextIndex (no behavior change), add Locator (line/page) on SearchHit, add TieredWikiSearch with three independent tiers over summaries/ (briefs + full body) and sources/ (whole-file .md + per-page PageIndex .json, never the whole long doc as one BM25 unit). - frontmatter.py: add resolve_description()/body_only() shared helpers (kept separate from agent.compiler._resolve_description, which is under active unrelated development). - agent/tools.py: add list_taxonomy_items()/get_taxonomy_item() for semantic browsing of persisted concepts/entities (pending candidates in PendingTopicsStore are structurally excluded). - No wiring into CLI/MCP/query-agent yet (follow-up PRs); WikiFullTextIndex and agent.tools.search_wiki keep their existing signature/behavior.
1 parent 73f50e2 commit 0025ea0

5 files changed

Lines changed: 678 additions & 30 deletions

File tree

‎openkb/agent/tools.py‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,17 @@
99

1010
import contextlib
1111
import json as _json
12+
from dataclasses import dataclass
1213
from pathlib import Path, PurePosixPath
14+
from typing import Literal
1315

16+
from openkb import frontmatter
1417
from openkb.locks import atomic_write_text
1518

19+
# Maps a taxonomy "kind" to its wiki subdirectory. Single source of truth for
20+
# list_taxonomy_items/get_taxonomy_item below.
21+
_TAXONOMY_DIRS: dict[str, str] = {"concept": "concepts", "entity": "entities"}
22+
1623

1724
def list_wiki_files(directory: str, wiki_root: str) -> str:
1825
"""List all Markdown files in a wiki subdirectory.
@@ -135,6 +142,103 @@ def get_wiki_page_content(doc_name: str, pages: str, wiki_root: str) -> str:
135142
return "\n\n".join(parts) + "\n\n"
136143

137144

145+
@dataclass(frozen=True)
146+
class TaxonomyItem:
147+
"""One persisted concept or entity page (never a pending candidate).
148+
149+
``PendingTopicsStore`` (see ``openkb.pending``) buffers not-yet-paged
150+
concept/entity candidates separately from the compiled ``.md`` pages
151+
under ``concepts/``/``entities/`` — this dataclass, and
152+
:func:`list_taxonomy_items`, only ever surface the latter, so a caller
153+
never sees an in-progress candidate as if it were a real page.
154+
"""
155+
156+
kind: Literal["concept", "entity"]
157+
slug: str
158+
path: str # wiki-root-relative, e.g. "concepts/attention.md"
159+
brief: str
160+
# Entity type (e.g. "person", "organization"); always None for concepts.
161+
type: str | None = None
162+
163+
164+
def list_taxonomy_items(wiki_root: str, kind: str | None = None) -> list[TaxonomyItem]:
165+
"""List persisted concept and/or entity pages with their one-line briefs.
166+
167+
Intended as the first step of the search strategy: browse this compact,
168+
semantically-scannable list and let the caller (an LLM) pick the
169+
relevant slug(s) by meaning — this is deliberately not a keyword search
170+
(see ``search_wiki`` for that, over summaries/sources only).
171+
172+
Args:
173+
wiki_root: Absolute path to the wiki root directory.
174+
kind: Restrict to ``"concept"`` or ``"entity"``; ``None`` returns both.
175+
176+
Returns:
177+
Items sorted by kind, then slug. Empty list if the KB has neither
178+
directory yet or both are empty.
179+
180+
Raises:
181+
ValueError: *kind* is neither ``None``, ``"concept"``, nor ``"entity"``.
182+
"""
183+
root = Path(wiki_root).resolve()
184+
kinds = [kind] if kind else ["concept", "entity"]
185+
for k in kinds:
186+
if k not in _TAXONOMY_DIRS:
187+
raise ValueError(f"Unknown kind {k!r}; expected 'concept' or 'entity'.")
188+
189+
items: list[TaxonomyItem] = []
190+
for k in kinds:
191+
directory = root / _TAXONOMY_DIRS[k]
192+
if not directory.is_dir():
193+
continue
194+
for md_file in sorted(directory.glob("*.md")):
195+
text = md_file.read_text(encoding="utf-8")
196+
fm = frontmatter.parse(text)
197+
brief = frontmatter.resolve_description(fm)
198+
etype = None
199+
if k == "entity":
200+
etype = str(fm.get("type") or "").strip().lower() or "other"
201+
items.append(
202+
TaxonomyItem(
203+
kind=k, # type: ignore[arg-type] # validated against _TAXONOMY_DIRS above
204+
slug=md_file.stem,
205+
path=f"{_TAXONOMY_DIRS[k]}/{md_file.name}",
206+
brief=brief,
207+
type=etype,
208+
)
209+
)
210+
return items
211+
212+
213+
def get_taxonomy_item(slug: str, wiki_root: str, kind: str | None = None) -> str:
214+
"""Read a persisted concept or entity page's full Markdown content.
215+
216+
Args:
217+
slug: Page slug (filename without ``.md``), e.g. ``"attention"``.
218+
wiki_root: Absolute path to the wiki root directory.
219+
kind: ``"concept"`` or ``"entity"`` to disambiguate a same-named
220+
slug; ``None`` checks ``concepts/`` first, then ``entities/``.
221+
222+
Returns:
223+
Full file content, or a "not found" message if no match exists in
224+
the requested (or either) directory.
225+
226+
Raises:
227+
ValueError: *kind* is neither ``None``, ``"concept"``, nor ``"entity"``.
228+
"""
229+
root = Path(wiki_root).resolve()
230+
kinds = [kind] if kind else ["concept", "entity"]
231+
for k in kinds:
232+
if k not in _TAXONOMY_DIRS:
233+
raise ValueError(f"Unknown kind {k!r}; expected 'concept' or 'entity'.")
234+
235+
for k in kinds:
236+
path = (root / _TAXONOMY_DIRS[k] / f"{slug}.md").resolve()
237+
if path.is_relative_to(root) and path.exists():
238+
return path.read_text(encoding="utf-8")
239+
return f"Taxonomy item not found: {slug}"
240+
241+
138242
def search_wiki(query: str, wiki_root: str, top_k: int = 5) -> str:
139243
"""Full-text (BM25) search over concepts/entities/summaries wiki pages.
140244

‎openkb/frontmatter.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,29 @@ def set_line(fm_block: str, key: str, value: str) -> str:
110110
def drop_line(fm_block: str, key: str) -> str:
111111
"""Remove any ``key:`` line from a frontmatter block (no-op if absent)."""
112112
return re.sub(rf"^{re.escape(key)}:.*\n?", "", fm_block, flags=re.MULTILINE)
113+
114+
115+
def resolve_description(fm: dict) -> str:
116+
"""Return a non-empty description string from a parsed frontmatter dict.
117+
118+
Checks ``description`` first, then the legacy ``brief`` key (pre-migration
119+
pages). Returns an empty string when neither key holds a non-blank value.
120+
Mirrors ``agent.compiler._resolve_description`` — kept as a separate,
121+
dependency-free copy here so callers outside the compiler (search/taxonomy
122+
tooling) don't need to import from ``agent.compiler``, which is under
123+
active, unrelated development.
124+
"""
125+
for key in ("description", "brief"):
126+
v = fm.get(key)
127+
if isinstance(v, str) and v.strip():
128+
return v.strip()
129+
return ""
130+
131+
132+
def body_only(text: str) -> str:
133+
"""Return *text* with any leading YAML frontmatter block removed.
134+
135+
Returns *text* unchanged when it has no well-formed frontmatter.
136+
"""
137+
parts = split(text)
138+
return parts[1] if parts is not None else text

0 commit comments

Comments
 (0)