# Python API Module Design

**Date:** 2026-02-24
**Issue:** [#5 — Expose Python API for entity/person queries used by brief-deck](https://github.com/tenfourty/kbx/issues/5)
**Status:** Approved

## Problem

brief-deck (local dashboard) depends on kbx internals: raw SQL against the schema, monkeypatching `Database._sqlite_conn` for thread safety, importing the private `_parse_glossary_terms` function, and reimplementing path validation. Schema changes in kbx will silently break brief-deck.

The MCP server (`mcp_server.py`) has the same problem — it duplicates CLI setup logic (config discovery, DB singleton, embedder loading) with no shared abstraction.

## Decision

Add a `KnowledgeBase` service class following the Client/Service pattern (same as guten-morgen's `MorgenClient`). One class owns DB + config + embedder lifecycle and exposes all operations as typed methods returning Pydantic models.

### Why this pattern

- **Pattern 1 (Client class)** — chosen. Single entry point, lifecycle managed, thread safety internalized. Matches guten-morgen, OpenAI SDK, Stripe SDK, peewee ORM.
- **Pattern 2 (Repository/Service layer)** — rejected. Only one backend (SQLite + LanceDB), extra indirection without benefit.
- **Pattern 3 (Module-level functions)** — rejected. Global state, no lifecycle management, painful testing.

## API Surface

### Constructor & Lifecycle

```python
from kb import KnowledgeBase

# Auto-discover config (like CLI does today)
kb = KnowledgeBase()

# Explicit config (for tests, brief-deck)
kb = KnowledgeBase(
    project_root=Path("/path/to/control-tower"),
    data_dir=Path("~/.config/kbx"),
    thread_safe=True,   # check_same_thread=False + WAL
)

# Context manager
with KnowledgeBase() as kb:
    kb.search("cloud migration")

# Manual cleanup
kb = KnowledgeBase(thread_safe=True)
kb.close()  # releases embedder GPU memory + closes DB
```

- `thread_safe=True`: opens connection with `check_same_thread=False`, enables WAL mode, sets busy timeout. Replaces brief-deck's connection monkeypatch.
- Embedder lazy-loaded on first `search()` or `index()` call.
- Constructor auto-discovers config via `find_config()` / `get_data_dir()` if no explicit paths given.

### Entity Operations

```python
kb.list_entities(entity_type="person")    # -> list[EntitySummary]
kb.get_entity("Eve Perrin")               # -> EntityDetail | None
kb.find_entities("Eve")                   # -> list[EntitySummary]
kb.get_entity_timeline("Eve", limit=10)   # -> list[TimelineEntry]
kb.toggle_entity_pin("Eve Perrin")        # -> EntityPinResult
```

### Document Operations

```python
kb.toggle_document_pin("memory/notes/cirs.md")  # -> DocumentPinResult
kb.get_document_pin("memory/notes/cirs.md")      # -> bool
kb.list_pinned_documents()                        # -> list[PinnedDocument]
kb.count_documents()                              # -> int
```

### Memory File Operations

```python
kb.read_memory_file("notes/cirs.md")              # -> str | None
kb.write_memory_file("notes/cirs.md", content)    # -> bool
kb.list_memory_tree()                              # -> list[MemoryTreeNode]
```

Path validation (no traversal, `.md` only, within `memory/`) is internal.

### Content Operations

```python
kb.list_glossary_terms()  # -> list[GlossaryEntry]
```

### Search & Context

```python
kb.search("cloud migration", limit=10)                    # -> SearchResponse
kb.search("cloud migration", fast=True)                    # -> SearchResponse (FTS only)
kb.search("q", doc_type="meeting", from_date="2026-01-01") # -> SearchResponse
kb.context(topic="cloud migration", fmt="compact")         # -> ContextOutput
kb.context(fmt="human")                                    # -> ContextOutput
```

Auto-staleness check runs internally before search/context.

### Indexing

```python
kb.index(full=False)  # -> IndexResult
```

Reuses the same embedder as search — no double-loading the ~1.1GB model.

## Types

### New types (added to `types.py`)

```python
class EntitySummary(StrictFrozen):
    id: int
    name: str
    entity_type: str
    metadata: dict[str, str]
    mention_count: int
    pinned: bool

class EntityDetail(StrictFrozen):
    id: int
    name: str
    entity_type: str
    aliases: list[str]
    metadata: dict[str, str]
    mention_count: int
    pinned: bool
    source_path: str | None
    facts: list[EntityFact]

class EntityFact(StrictFrozen):
    text: str
    date: str | None

class TimelineEntry(StrictFrozen):
    title: str
    date: str | None
    path: str

class EntityPinResult(StrictFrozen):
    name: str
    pinned: bool

class DocumentPinResult(StrictFrozen):
    path: str
    pinned: bool

class MemoryTreeNode(StrictFrozen):
    name: str
    node_type: str  # "file" | "dir"
    path: str
    pinned: bool = False
    children: list[MemoryTreeNode] = []
    count: int = 0
```

### Reused existing types

- `SearchResponse`, `SearchResult`, `SearchMeta`
- `ContextOutput`, `ContextStats`, `ContextEntitySummary`
- `IndexResult`
- `PinnedDocument`
- `GlossaryEntry`

## Module Layout

```
src/kb/
  api.py          # KnowledgeBase class (~300-400 lines)
  __init__.py     # re-export: from kb.api import KnowledgeBase
  types.py        # + 7 new types
  ...             # everything else unchanged
```

One new file. Existing modules are wrapped, not refactored.

## Delegation Map

| API Method | Delegates to |
|-----------|-------------|
| `search()` | `kb.search.search()` |
| `context()` | `kb.context.generate_context()` |
| `index()` | `kb.indexer.index_all()` |
| `list_entities()` | SQL on entities + entity_mentions |
| `get_entity()` | SQL + `config.find_entity()` |
| `get_entity_timeline()` | SQL JOIN entity_mentions + documents |
| `find_entities()` | `config.find_entities()` |
| `toggle_entity_pin()` | SQL UPDATE entities |
| `toggle_document_pin()` | SQL UPDATE documents |
| `list_glossary_terms()` | `kb.glossary.list_terms()` |
| `read/write_memory_file()` | Path validation + file I/O |
| `list_memory_tree()` | Directory walk + pinned doc query |

## Memory Management

The `KnowledgeBase` class improves embedder lifecycle for long-running consumers:

- **Before:** brief-deck and MCP server load the embedder (~1.1GB) once but never release it. Search and index use separate embedder instances. No coordinated cleanup.
- **After:** One embedder instance shared across search + index + auto-reindex. `close()` releases GPU memory. FastAPI lifespan and MCP shutdown call `close()`.

CLI is unaffected (each command is a fresh process).

## Consumer Migration

### brief-deck (before: ~420 lines in services/kb.py)

```python
# After: ~30 lines
from kb import KnowledgeBase

_kb: KnowledgeBase | None = None

def get_kb() -> KnowledgeBase:
    global _kb
    if _kb is None:
        _kb = KnowledgeBase(thread_safe=True)
    return _kb
```

All raw SQL, connection hacking, path validation, private imports, and embedder management are eliminated.

### MCP server

```python
kb = KnowledgeBase()

@mcp.tool()
def kb_search(query: str, ...) -> str:
    return kb.search(query, ...).model_dump_json()
```

Eliminates duplicated setup logic.

### CLI

Optionally migrated — CLI can continue calling backend modules directly (it works fine today) or switch to `KnowledgeBase()` per command. Low priority.
