Skip to content

Commit 92f8f41

Browse files
committed
feat(api): configurable entity_types (global + per-KB) via the config API
entity_types (the entity-extraction vocabulary) is now surfaced through the config read/write API, layering global.yaml -> KB config.yaml like the other scalars: a KB list overrides the global list wholesale, an explicit null inherits, and unset falls back to DEFAULT_ENTITY_TYPES. The compiler already consumed config["entity_types"] via resolve_entity_types; this just exposes it. - GLOBAL_SCALAR_KEYS gains "entity_types" (layering + per-key `sources` tracking; the value-not-None-wins rule is type-agnostic, so it works for a list). - _KbConfigWritable / GlobalConfigValues / KbConfigResponse / GlobalConfigResponse carry entity_types; read_kb_config/read_global_config report the cleaned EFFECTIVE list (resolve_entity_types) plus the raw global value for the badge. - PATCH /api/v1/kb/config and PATCH /api/v1/config accept entity_types. Tests: KB override (cleaned + source 'kb') + null revert, global patch, global inheritance; updated the global-defaults shape assertion. Frontend UI follows. Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG
1 parent a69c7d4 commit 92f8f41

4 files changed

Lines changed: 83 additions & 2 deletions

File tree

openkb/api_config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
load_global_config,
3535
resolve_credential_bundle,
3636
resolve_effective_config,
37+
resolve_entity_types,
3738
save_config,
3839
)
3940
from openkb.locks import atomic_write_text
@@ -150,13 +151,17 @@ def read_kb_config(kb_dir: Path) -> KbConfigResponse:
150151
model=effective["model"],
151152
language=effective["language"],
152153
pageindex_threshold=effective["pageindex_threshold"],
154+
# Cleaned effective list (what the compiler will use), not the raw stored
155+
# value — resolve_entity_types defaults + dedupes + ensures "other".
156+
entity_types=resolve_entity_types(effective),
153157
openai_api_base=bundle.base_url,
154158
has_api_key=bundle.api_key is not None,
155159
sources=sources,
156160
global_values=GlobalConfigValues(
157161
model=global_config.get("model"),
158162
language=global_config.get("language"),
159163
pageindex_threshold=global_config.get("pageindex_threshold"),
164+
entity_types=global_config.get("entity_types"),
160165
),
161166
)
162167

@@ -262,6 +267,8 @@ def read_global_config() -> GlobalConfigResponse:
262267
model=gc.get("model", DEFAULT_CONFIG["model"]),
263268
language=gc.get("language", DEFAULT_CONFIG["language"]),
264269
pageindex_threshold=gc.get("pageindex_threshold", DEFAULT_CONFIG["pageindex_threshold"]),
270+
# Effective global vocabulary (cleaned; defaults to DEFAULT_ENTITY_TYPES).
271+
entity_types=resolve_entity_types(gc),
265272
# Report the EFFECTIVE root (env > global.yaml kb_root > default) via the
266273
# module object so a test-monkeypatched GLOBAL_CONFIG_DIR is honored.
267274
kb_root=str(_config_module.kb_root_dir()),

openkb/api_models.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,10 @@ class _KbConfigWritable(BaseModel):
401401
model: str | None = None
402402
language: str | None = None
403403
pageindex_threshold: int | None = None
404+
# Entity-type vocabulary for extraction. A list REPLACES the layer below; an
405+
# explicit null reverts to inherited. Values are cleaned/deduped and "other"
406+
# is always ensured at read time (config.resolve_entity_types).
407+
entity_types: list[str] | None = None
404408

405409

406410
# Single source of truth for the writable config keys (derived from the model
@@ -409,17 +413,20 @@ class _KbConfigWritable(BaseModel):
409413

410414

411415
class GlobalConfigValues(BaseModel):
412-
"""Raw global-layer scalar values (null where global.yaml is silent)."""
416+
"""Raw global-layer values (null where global.yaml is silent)."""
413417

414418
model: str | None = None
415419
language: str | None = None
416420
pageindex_threshold: int | None = None
421+
entity_types: list[str] | None = None
417422

418423

419424
class GlobalConfigResponse(BaseModel):
420425
model: str
421426
language: str
422427
pageindex_threshold: int
428+
# Effective global entity-type vocabulary (cleaned; always includes "other").
429+
entity_types: list[str]
423430
# Effective KB root that kb_root_dir() would return (env OPENKB_KB_ROOT >
424431
# global.yaml kb_root > default <config>/kbs). kb_root_env_pinned is True
425432
# when OPENKB_KB_ROOT is set — a global.yaml kb_root is then ineffective, so
@@ -453,6 +460,8 @@ class KbConfigResponse(BaseModel):
453460
model: str
454461
language: str
455462
pageindex_threshold: int
463+
# Effective entity-type vocabulary (cleaned; always includes "other").
464+
entity_types: list[str]
456465
openai_api_base: str | None
457466
has_api_key: bool
458467
# Additive (non-breaking): which layer supplied each scalar's effective

openkb/config.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,16 @@ def load_global_config() -> dict[str, Any]:
483483
# Whitelisted scalar keys that global.yaml may provide as shared defaults for
484484
# every KB. Non-scalar global keys (known_kbs, kb_aliases, default_kb) are NOT
485485
# config and must never leak into a KB's effective runtime config.
486-
GLOBAL_SCALAR_KEYS: tuple[str, ...] = ("model", "language", "pageindex_threshold")
486+
# Config keys that layer global.yaml -> KB config.yaml with per-key `sources`
487+
# tracking (a KB explicit null = inherit). Mostly scalars; `entity_types` is the
488+
# one list-valued member — the layering rule (a non-null value wins over the
489+
# layer below) is type-agnostic, so a KB list overrides the global list wholesale.
490+
GLOBAL_SCALAR_KEYS: tuple[str, ...] = (
491+
"model",
492+
"language",
493+
"pageindex_threshold",
494+
"entity_types",
495+
)
487496

488497

489498
def resolve_effective_config(kb_dir: Path) -> tuple[dict[str, Any], dict[str, str]]:

tests/test_api.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2204,6 +2204,60 @@ def test_kb_config_patch_updates_model_only(monkeypatch, kb_dir):
22042204
assert saved["model"] == "claude-opus"
22052205

22062206

2207+
def test_kb_config_patch_sets_and_reverts_entity_types(monkeypatch, kb_dir):
2208+
client = _client(monkeypatch)
2209+
kb = _use_named_kb(monkeypatch, kb_dir)
2210+
2211+
# Set a custom vocabulary: stored raw, read back cleaned (lowercased, unsafe
2212+
# chars stripped, deduped, "other" ensured) with source 'kb'.
2213+
r = client.patch(
2214+
"/api/v1/kb/config",
2215+
json={"kb": kb, "config": {"entity_types": ["Person", "Team!"]}},
2216+
headers=_auth(),
2217+
)
2218+
assert r.status_code == 200, r.text
2219+
body = r.json()
2220+
assert body["entity_types"] == ["person", "team", "other"]
2221+
assert body["sources"]["entity_types"] == "kb"
2222+
saved = yaml.safe_load((kb_dir / ".openkb" / "config.yaml").read_text())
2223+
assert saved["entity_types"] == ["Person", "Team!"] # raw value persisted
2224+
2225+
# Revert (explicit null) -> inherit; with no global set, falls to default.
2226+
r = client.patch(
2227+
"/api/v1/kb/config", json={"kb": kb, "config": {"entity_types": None}}, headers=_auth()
2228+
)
2229+
assert r.status_code == 200, r.text
2230+
body = r.json()
2231+
assert body["sources"]["entity_types"] == "default"
2232+
assert body["entity_types"][-1] == "other"
2233+
2234+
2235+
def test_global_config_patch_entity_types(monkeypatch, tmp_path):
2236+
monkeypatch.setattr("openkb.config.GLOBAL_CONFIG_PATH", tmp_path / "global.yaml")
2237+
monkeypatch.setattr("openkb.config.GLOBAL_CONFIG_DIR", tmp_path)
2238+
monkeypatch.delenv("OPENKB_KB_ROOT", raising=False)
2239+
client = _client(monkeypatch)
2240+
r = client.patch(
2241+
"/api/v1/config", json={"config": {"entity_types": ["Org", "Law!"]}}, headers=_auth()
2242+
)
2243+
assert r.status_code == 200, r.text
2244+
assert r.json()["entity_types"] == ["org", "law", "other"] # cleaned effective list
2245+
2246+
2247+
def test_kb_config_get_reports_global_source_for_entity_types(monkeypatch, kb_dir, tmp_path):
2248+
monkeypatch.setattr("openkb.config.GLOBAL_CONFIG_PATH", tmp_path / "global.yaml")
2249+
monkeypatch.setattr("openkb.config.GLOBAL_CONFIG_DIR", tmp_path)
2250+
from openkb.config import save_global_config
2251+
2252+
save_global_config({"entity_types": ["team", "org"]})
2253+
client = _client(monkeypatch)
2254+
kb = _use_named_kb(monkeypatch, kb_dir)
2255+
body = client.get("/api/v1/kb/config", params={"kb": kb}, headers=_auth()).json()
2256+
assert body["sources"]["entity_types"] == "global" # inherited from global.yaml
2257+
assert body["entity_types"] == ["team", "org", "other"]
2258+
assert body["global_values"]["entity_types"] == ["team", "org"] # raw global for the badge
2259+
2260+
22072261
def test_kb_config_patch_unknown_field_is_400(monkeypatch, kb_dir):
22082262
client = _client(monkeypatch)
22092263
kb = _use_named_kb(monkeypatch, kb_dir)
@@ -2477,6 +2531,8 @@ def test_global_config_get_defaults_when_absent(monkeypatch, tmp_path):
24772531
"model": "gpt-5.4",
24782532
"language": "en",
24792533
"pageindex_threshold": 20,
2534+
# Default entity-type vocabulary when global.yaml sets none.
2535+
"entity_types": ["person", "organization", "place", "product", "work", "event", "other"],
24802536
# kb_root reports the EFFECTIVE root; with no env/global override it is
24812537
# the default <GLOBAL_CONFIG_DIR>/kbs, and env_pinned is False.
24822538
"kb_root": str((tmp_path / "kbs").resolve()),

0 commit comments

Comments
 (0)