diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index 7146fa88..ce1b11cd 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -6,6 +6,9 @@ export interface GlobalConfig { model: string language: string pageindex_threshold: number + /** CLEANED effective global entity-extraction vocabulary (always includes + * "other"). Mirrors KbConfig.entity_types. */ + entity_types: string[] /** Plaintext global-default LLM base URL (a config value, not a secret); * null if unset. Mirrors KbConfig.openai_api_base. */ openai_api_base?: string | null @@ -30,6 +33,9 @@ export interface GlobalConfigPatch { model?: string | null language?: string | null pageindex_threshold?: number | null + /** A list sets the global vocabulary; `null` reverts to the built-in + * default. Cleaned server-side (lowercase, `[a-z0-9 _-]`, + "other"). */ + entity_types?: string[] | null } api_key?: string | null openai_api_base?: string | null diff --git a/frontend/src/api/kb.ts b/frontend/src/api/kb.ts index 6309b21b..a4b37602 100644 --- a/frontend/src/api/kb.ts +++ b/frontend/src/api/kb.ts @@ -54,17 +54,21 @@ export interface KbConfig { model: string language: string pageindex_threshold: number + /** CLEANED effective entity-extraction vocabulary (always includes "other"). */ + entity_types: string[] /** Plaintext LLM base URL (a config value, not a credential); null if unset. */ openai_api_base: string | null /** Presence flag only — the raw key value is NEVER returned by the API. */ has_api_key: boolean - /** Which layer supplied each scalar's effective value. */ - sources: Record<"model" | "language" | "pageindex_threshold", ConfigSource> - /** Raw global-layer scalars (null where global.yaml is silent). */ + /** Which layer supplied each field's effective value. */ + sources: Record<"model" | "language" | "pageindex_threshold" | "entity_types", ConfigSource> + /** Raw global-layer values (null where global.yaml is silent). */ global_values: { model: string | null language: string | null pageindex_threshold: number | null + /** RAW global list (not cleaned) — for the inherited badge. */ + entity_types: string[] | null } } @@ -76,7 +80,11 @@ export interface KbConfig { * key" request, not "unchanged" or "clear". */ export interface KbConfigPatch { - config?: Partial> + config?: Partial> & { + /** A list sets/overrides for this KB; `null` reverts to inherited. Values + * are cleaned server-side (lowercase, `[a-z0-9 _-]`, dedupe, + "other"). */ + entity_types?: string[] | null + } api_key?: string | null openai_api_base?: string | null } diff --git a/frontend/src/components/EntityTypesEditor.tsx b/frontend/src/components/EntityTypesEditor.tsx new file mode 100644 index 00000000..1b81eeff --- /dev/null +++ b/frontend/src/components/EntityTypesEditor.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { X } from 'lucide-react' + +/** The backend's catch-all bucket: it is re-appended server-side on every + * write, so the editor renders it as a fixed, non-removable chip. */ +const ALWAYS_INCLUDED = 'other' + +/** + * Chips editor for the entity-extraction vocabulary, shared by the per-KB + * override row (KbSettingsSheet) and the global editor (Settings › general). + * Controlled: renders `value` as removable chips + an add input (Enter to add, + * commas split), and calls `onChange` with the FULL next list on each + * add/remove. Client-side it only trims/lowercases and drops empties/dupes — + * the authoritative cleaning (charset, dedupe, "other") happens server-side. + */ +export default function EntityTypesEditor({ + value, + disabled, + onChange, +}: { + /** Current list (server-cleaned effective list, or local draft state). */ + value: string[] + disabled?: boolean + /** Receives the complete next list after an add/remove. */ + onChange: (next: string[]) => void +}) { + const { t } = useTranslation('common') + const [draft, setDraft] = useState('') + + const add = () => { + const seen = new Set(value.map((v) => v.toLowerCase())) + const added: string[] = [] + for (const part of draft.split(',')) { + const v = part.trim().toLowerCase() + if (v === '' || seen.has(v)) continue + seen.add(v) + added.push(v) + } + setDraft('') + if (added.length > 0) onChange([...value, ...added]) + } + + return ( +
+ {value.map((item) => ( + + {item} + {item === ALWAYS_INCLUDED ? ( + {t('entityTypes.always')} + ) : ( + + )} + + ))} + setDraft(e.target.value)} + onKeyDown={(e) => { + // Enter confirms IME composition first; only a real Enter adds. + if (e.key === 'Enter' && !e.nativeEvent.isComposing) { + e.preventDefault() + add() + } + }} + placeholder={t('entityTypes.placeholder')} + aria-label={t('entityTypes.placeholder')} + className="h-6 min-w-[140px] flex-1 bg-transparent text-[12.5px] font-mono2 outline-none placeholder:text-muted-foreground/70 disabled:opacity-60" + /> +
+ ) +} diff --git a/frontend/src/components/KbSettingsSheet.tsx b/frontend/src/components/KbSettingsSheet.tsx index e0865176..731d87e5 100644 --- a/frontend/src/components/KbSettingsSheet.tsx +++ b/frontend/src/components/KbSettingsSheet.tsx @@ -16,6 +16,7 @@ import type { SseEvent } from '@/api/client' import { Switch } from '@/components/ui/switch' import { cn } from '@/lib/utils' import { UnLanguageDatalist, UN_LANG_LIST_ID } from '@/components/UnLanguageDatalist' +import EntityTypesEditor from '@/components/EntityTypesEditor' const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e)) @@ -169,6 +170,22 @@ function KbConfigSection({ kb }: { kb: string }) { [kb, apply, t], ) + // Persist the entity-types override (list) or revert to inherited (null). + // Always adopts the response: the row then shows the server-CLEANED list. + const setEntityTypesOverride = useCallback( + async (value: string[] | null) => { + setBusy(true) + try { + apply(await patchKbConfig(kb, { config: { entity_types: value } })) + } catch (e) { + toast.error(t('common:saveError', { error: errMsg(e) })) + } finally { + setBusy(false) + } + }, + [kb, apply, t], + ) + const saveCredentials = useCallback(async () => { if (!config) return setBusy(true) @@ -258,6 +275,13 @@ function KbConfigSection({ kb }: { kb: string }) { onSet={(v) => setOverride('pageindex_threshold', Number(v))} onRevert={() => setOverride('pageindex_threshold', null)} /> + setEntityTypesOverride(null)} + />

{t('kbSettings:credHeading')}

@@ -385,6 +409,64 @@ function OverrideRow({ ) } +/** OverrideRow's list-shaped sibling for the entity-extraction vocabulary: + * the same 为本库覆盖 switch + inherited badge, but the override editor is a + * chips list (EntityTypesEditor) and every add/remove persists immediately — + * the chips always show the server-cleaned effective list. */ +function EntityTypesRow({ + source, effective, busy, onSet, onRevert, +}: { + source: ConfigSource + /** Cleaned effective list (always includes "other") — shown as-is in the + * inherited badge, so it matches the vocabulary the compiler actually uses. */ + effective: string[] + busy: boolean + onSet: (value: string[]) => void + onRevert: () => void +}) { + const { t } = useTranslation(['kbSettings', 'common']) + const overridden = source === 'kb' + const label = t('common:fields.entityTypes') + + const inheritedBadge = + source === 'global' + ? t('kbSettings:inheritGlobal', { value: effective.join(', ') }) + : t('kbSettings:inheritDefault', { value: effective.join(', ') }) + + return ( +
+
+ + + {t('kbSettings:override')} + (v ? onSet(effective) : onRevert())} + aria-label={t('kbSettings:overrideAria', { label })} + /> + +
+ {overridden ? ( +
+ +
+ ) : ( +
+ {inheritedBadge} +
+ )} +

{t('kbSettings:entityTypesNote')}

+
+ ) +} + /** Per-doc row accumulated from a recompile SSE stream (from the `doc` event). * Moved verbatim from `pages/KbDetail.tsx` (formerly its 任务 tab). */ interface RecompileDoc { diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index d22624fd..91430075 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -13,7 +13,13 @@ "fields": { "model": "Model", "wikiLanguage": "Wiki output language", - "threshold": "PageIndex threshold (pages)" + "threshold": "PageIndex threshold (pages)", + "entityTypes": "Entity types" + }, + "entityTypes": { + "placeholder": "Add a type, press Enter", + "removeAria": "Remove type {{type}}", + "always": "always included" }, "errors": { "requestFailed": "Request failed", diff --git a/frontend/src/locales/en/kbSettings.json b/frontend/src/locales/en/kbSettings.json index a3cecab6..86a4e0e4 100644 --- a/frontend/src/locales/en/kbSettings.json +++ b/frontend/src/locales/en/kbSettings.json @@ -18,6 +18,7 @@ "inheritDefault": "Inherited · default ({{value}})", "override": "Override for this KB", "overrideAria": "Override {{label}} for this KB", + "entityTypesNote": "Changes apply to future recompiles only; existing entity pages keep their current type.", "unnamed": "(unnamed)", "recompileFailed": "Recompile failed", "watchStarted": "File watching started", diff --git a/frontend/src/locales/en/settings.json b/frontend/src/locales/en/settings.json index 4d76bc39..30ccd99b 100644 --- a/frontend/src/locales/en/settings.json +++ b/frontend/src/locales/en/settings.json @@ -21,6 +21,8 @@ }, "general": { "langDesc": "The output language written into the compile prompt, e.g. en / 中文 / 日本語", + "entityTypesDesc": "The vocabulary used to classify entities extracted at compile time; each KB can inherit or override it in its own settings.", + "entityTypesNote": "Changes apply to future recompiles only; existing entity pages keep their current type.", "kbRootLabel": "Knowledge base root", "kbRootDesc": "Default location for new knowledge bases (/); leave empty to restore the built-in default. Individual KBs can set a custom path at creation.", "kbRootPlaceholder": "e.g. ~/openkb", diff --git a/frontend/src/locales/zh/common.json b/frontend/src/locales/zh/common.json index e76945a7..09edf636 100644 --- a/frontend/src/locales/zh/common.json +++ b/frontend/src/locales/zh/common.json @@ -13,7 +13,13 @@ "fields": { "model": "模型", "wikiLanguage": "Wiki 输出语言", - "threshold": "PageIndex 阈值(页数)" + "threshold": "PageIndex 阈值(页数)", + "entityTypes": "实体类型" + }, + "entityTypes": { + "placeholder": "输入类型,按 Enter 添加", + "removeAria": "移除类型 {{type}}", + "always": "始终包含" }, "errors": { "requestFailed": "请求失败", diff --git a/frontend/src/locales/zh/kbSettings.json b/frontend/src/locales/zh/kbSettings.json index 85d42dcc..7f122e4a 100644 --- a/frontend/src/locales/zh/kbSettings.json +++ b/frontend/src/locales/zh/kbSettings.json @@ -18,6 +18,7 @@ "inheritDefault": "继承 · 默认({{value}})", "override": "为本库覆盖", "overrideAria": "{{label}} 为本库覆盖", + "entityTypesNote": "改动仅影响之后的重新编译;已有实体页会保留其当前类型。", "unnamed": "(未命名)", "recompileFailed": "重新编译失败", "watchStarted": "已启动文件监听", diff --git a/frontend/src/locales/zh/settings.json b/frontend/src/locales/zh/settings.json index 85f3e90e..5d60a145 100644 --- a/frontend/src/locales/zh/settings.json +++ b/frontend/src/locales/zh/settings.json @@ -21,6 +21,8 @@ }, "general": { "langDesc": "写入编译 prompt 的输出语言,例如 en / 中文 / 日本語", + "entityTypesDesc": "编译时为抽取出的实体分类所用的类型词表;各知识库可在其设置中继承或覆盖。", + "entityTypesNote": "改动仅影响之后的重新编译;已有实体页会保留其当前类型。", "kbRootLabel": "知识库根目录", "kbRootDesc": "新建知识库的默认位置(<根目录>/<名称>);留空则恢复内置默认值。单个知识库可在创建时设置自定义路径。", "kbRootPlaceholder": "例如 ~/openkb", diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 3f361177..6b445513 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -5,6 +5,7 @@ import { toast } from 'sonner' import { getGlobalConfig, patchGlobalConfig, type GlobalConfig, type GlobalConfigPatch } from '@/api/config' import ConnectorCards from '@/components/ConnectorCards' import AboutTab from '@/components/AboutTab' +import EntityTypesEditor from '@/components/EntityTypesEditor' import { cn } from '@/lib/utils' import { UnLanguageDatalist, UN_LANG_LIST_ID } from '@/components/UnLanguageDatalist' @@ -39,6 +40,10 @@ export default function Settings() { const [model, setModel] = useState('') const [language, setLanguage] = useState('') const [threshold, setThreshold] = useState('') + // Entity-extraction vocabulary, edited as chips. Seeded from the CLEANED + // effective list (always includes "other"); diffed order-sensitively in + // buildPatch. Server re-cleans on save, so client edits stay lightweight. + const [entityTypes, setEntityTypes] = useState([]) // KB root directory. Emptying a previously-set root clears it (null → default), // mirroring the credential-base's empty→null discipline in buildPatch. const [kbRoot, setKbRoot] = useState('') @@ -58,6 +63,7 @@ export default function Settings() { setModel(c.model) setLanguage(c.language) setThreshold(String(c.pageindex_threshold)) + setEntityTypes(c.entity_types) setKbRoot(c.kb_root ?? '') setApiBase(c.openai_api_base ?? '') setApiKey('') @@ -103,6 +109,14 @@ export default function Settings() { if (threshold.trim() !== '' && Number.isInteger(n) && n >= 1 && n !== config.pageindex_threshold) { cfg.pageindex_threshold = n } + // Order-INSENSITIVE compare: the vocabulary is a set (the compiler treats + // it as an allowlist), and removing-then-re-adding a type appends it at the + // end — so a same-set reorder must NOT dirty the form or persist a no-op. + const a = [...entityTypes].sort() + const b = [...config.entity_types].sort() + if (a.length !== b.length || a.some((v, i) => v !== b[i])) { + cfg.entity_types = entityTypes + } if (Object.keys(cfg).length > 0) patch.config = cfg // When kb_root is env-pinned the field is read-only and the effective value // is the OPENKB_KB_ROOT env root, so never diff/emit it — a Save would @@ -118,7 +132,7 @@ export default function Settings() { if (clearKey) patch.api_key = null else if (apiKey !== '') patch.api_key = apiKey return { patch, dirty: Object.keys(patch).length > 0 } - }, [config, model, language, threshold, kbRoot, apiBase, apiKey, clearKey]) + }, [config, model, language, threshold, entityTypes, kbRoot, apiBase, apiKey, clearKey]) const dirty = useMemo(() => buildPatch().dirty, [buildPatch]) @@ -313,6 +327,19 @@ export default function Settings() {
+
+ +

{t('settings:general.entityTypesDesc')}

+
+ +
+

{t('settings:general.entityTypesNote')}

+
+

{t('settings:general.kbRootDesc')}

diff --git a/openkb/api_config.py b/openkb/api_config.py index 2d1b0bc7..6491731b 100644 --- a/openkb/api_config.py +++ b/openkb/api_config.py @@ -34,6 +34,7 @@ load_global_config, resolve_credential_bundle, resolve_effective_config, + resolve_entity_types, save_config, ) from openkb.locks import atomic_write_text @@ -150,6 +151,9 @@ def read_kb_config(kb_dir: Path) -> KbConfigResponse: model=effective["model"], language=effective["language"], pageindex_threshold=effective["pageindex_threshold"], + # Cleaned effective list (what the compiler will use), not the raw stored + # value — resolve_entity_types defaults + dedupes + ensures "other". + entity_types=resolve_entity_types(effective, warn=False), openai_api_base=bundle.base_url, has_api_key=bundle.api_key is not None, sources=sources, @@ -157,6 +161,7 @@ def read_kb_config(kb_dir: Path) -> KbConfigResponse: model=global_config.get("model"), language=global_config.get("language"), pageindex_threshold=global_config.get("pageindex_threshold"), + entity_types=global_config.get("entity_types"), ), ) @@ -262,6 +267,8 @@ def read_global_config() -> GlobalConfigResponse: model=gc.get("model", DEFAULT_CONFIG["model"]), language=gc.get("language", DEFAULT_CONFIG["language"]), pageindex_threshold=gc.get("pageindex_threshold", DEFAULT_CONFIG["pageindex_threshold"]), + # Effective global vocabulary (cleaned; defaults to DEFAULT_ENTITY_TYPES). + entity_types=resolve_entity_types(gc, warn=False), # Report the EFFECTIVE root (env > global.yaml kb_root > default) via the # module object so a test-monkeypatched GLOBAL_CONFIG_DIR is honored. kb_root=str(_config_module.kb_root_dir()), diff --git a/openkb/api_models.py b/openkb/api_models.py index 0a1f5076..21071d0a 100644 --- a/openkb/api_models.py +++ b/openkb/api_models.py @@ -401,6 +401,10 @@ class _KbConfigWritable(BaseModel): model: str | None = None language: str | None = None pageindex_threshold: int | None = None + # Entity-type vocabulary for extraction. A list REPLACES the layer below; an + # explicit null reverts to inherited. Values are cleaned/deduped and "other" + # is always ensured at read time (config.resolve_entity_types). + entity_types: list[str] | None = None # Single source of truth for the writable config keys (derived from the model @@ -409,17 +413,20 @@ class _KbConfigWritable(BaseModel): class GlobalConfigValues(BaseModel): - """Raw global-layer scalar values (null where global.yaml is silent).""" + """Raw global-layer values (null where global.yaml is silent).""" model: str | None = None language: str | None = None pageindex_threshold: int | None = None + entity_types: list[str] | None = None class GlobalConfigResponse(BaseModel): model: str language: str pageindex_threshold: int + # Effective global entity-type vocabulary (cleaned; always includes "other"). + entity_types: list[str] # Effective KB root that kb_root_dir() would return (env OPENKB_KB_ROOT > # global.yaml kb_root > default /kbs). kb_root_env_pinned is True # when OPENKB_KB_ROOT is set — a global.yaml kb_root is then ineffective, so @@ -453,6 +460,8 @@ class KbConfigResponse(BaseModel): model: str language: str pageindex_threshold: int + # Effective entity-type vocabulary (cleaned; always includes "other"). + entity_types: list[str] openai_api_base: str | None has_api_key: bool # Additive (non-breaking): which layer supplied each scalar's effective diff --git a/openkb/config.py b/openkb/config.py index c454cb05..95ca8691 100644 --- a/openkb/config.py +++ b/openkb/config.py @@ -15,13 +15,7 @@ logger = logging.getLogger(__name__) -DEFAULT_CONFIG: dict[str, Any] = { - "model": "gpt-5.4", - "language": "en", - "pageindex_threshold": 20, -} - -# Default entity-type vocabulary. Overridable per-KB via the optional +# Default entity-type vocabulary. Overridable globally / per-KB via the optional # ``entity_types:`` config key (see ``resolve_entity_types``). DEFAULT_ENTITY_TYPES: tuple[str, ...] = ( "person", @@ -33,6 +27,17 @@ "other", ) +DEFAULT_CONFIG: dict[str, Any] = { + "model": "gpt-5.4", + "language": "en", + "pageindex_threshold": 20, + # A GLOBAL_SCALAR_KEY like the three above, so the merged `effective` dict + # always carries it (the layering/`sources` logic is type-agnostic). A + # global/KB list overrides it wholesale; resolve_entity_types cleans the + # effective value on read. + "entity_types": list(DEFAULT_ENTITY_TYPES), +} + GLOBAL_CONFIG_DIR = Path.home() / ".config" / "openkb" GLOBAL_CONFIG_PATH = GLOBAL_CONFIG_DIR / "global.yaml" GLOBAL_CONFIG_LOCK_PATH = GLOBAL_CONFIG_DIR / "global.lock" @@ -88,7 +93,7 @@ def _load_global_config_unlocked() -> dict[str, Any]: return {} -def resolve_entity_types(config: dict) -> list[str]: +def resolve_entity_types(config: dict, *, warn: bool = True) -> list[str]: """Resolve the effective entity-type list from a loaded config dict. If ``config["entity_types"]`` is a non-empty list, each string item is @@ -98,18 +103,23 @@ def resolve_entity_types(config: dict) -> list[str]: de-duped (order preserving) and ``"other"`` is always appended when missing (it is the coercion fallback). Otherwise — key absent, not a list, empty, or fully malformed — :data:`DEFAULT_ENTITY_TYPES` is returned, so behavior - is byte-identical to the default. A warning is logged only when - ``entity_types`` was present-but-malformed. + is byte-identical to the default. + + ``warn`` logs a warning when ``entity_types`` is present-but-malformed. It is + ``True`` for the compile path (a real config problem the author should see) + and passed ``False`` by the config-READ endpoints, which run on every GET and + must not spam the log for a malformed value. """ raw = config.get("entity_types") if raw is None: return list(DEFAULT_ENTITY_TYPES) if not isinstance(raw, list): - logger.warning( - "config: 'entity_types' must be a list of strings, got %s — " - "falling back to the default entity types.", - type(raw).__name__, - ) + if warn: + logger.warning( + "config: 'entity_types' must be a list of strings, got %s — " + "falling back to the default entity types.", + type(raw).__name__, + ) return list(DEFAULT_ENTITY_TYPES) cleaned: list[str] = [] for x in raw: @@ -119,10 +129,11 @@ def resolve_entity_types(config: dict) -> list[str]: if s and s not in cleaned: cleaned.append(s) if not cleaned: - logger.warning( - "config: 'entity_types' was present but yielded no usable values — " - "falling back to the default entity types.", - ) + if warn: + logger.warning( + "config: 'entity_types' was present but yielded no usable values — " + "falling back to the default entity types.", + ) return list(DEFAULT_ENTITY_TYPES) if "other" not in cleaned: cleaned.append("other") @@ -483,7 +494,16 @@ def load_global_config() -> dict[str, Any]: # Whitelisted scalar keys that global.yaml may provide as shared defaults for # every KB. Non-scalar global keys (known_kbs, kb_aliases, default_kb) are NOT # config and must never leak into a KB's effective runtime config. -GLOBAL_SCALAR_KEYS: tuple[str, ...] = ("model", "language", "pageindex_threshold") +# Config keys that layer global.yaml -> KB config.yaml with per-key `sources` +# tracking (a KB explicit null = inherit). Mostly scalars; `entity_types` is the +# one list-valued member — the layering rule (a non-null value wins over the +# layer below) is type-agnostic, so a KB list overrides the global list wholesale. +GLOBAL_SCALAR_KEYS: tuple[str, ...] = ( + "model", + "language", + "pageindex_threshold", + "entity_types", +) def resolve_effective_config(kb_dir: Path) -> tuple[dict[str, Any], dict[str, str]]: @@ -524,10 +544,12 @@ def resolve_effective_config(kb_dir: Path) -> tuple[dict[str, Any], dict[str, st if not isinstance(kb_config, dict): kb_config = {} for key, value in kb_config.items(): - # A scalar explicitly nulled in config.yaml means "inherit": don't - # let it clobber the global/default layer. The gate is on - # GLOBAL_SCALAR_KEYS so non-scalar nulls (parallel_tool_calls) stay. - if key in GLOBAL_SCALAR_KEYS and value is None: + # A GLOBAL_SCALAR_KEYS value explicitly nulled — or an empty + # entity_types list, since an empty vocabulary is meaningless — + # means "inherit": don't clobber the global/default layer, and keep + # `sources` reporting the layer that actually supplied the value. The + # gate is on GLOBAL_SCALAR_KEYS so non-scalar nulls (parallel_tool_calls) stay. + if key in GLOBAL_SCALAR_KEYS and (value is None or value == []): continue effective[key] = value if key in GLOBAL_SCALAR_KEYS: diff --git a/tests/test_api.py b/tests/test_api.py index ce5cf815..b9e0f91a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2204,6 +2204,60 @@ def test_kb_config_patch_updates_model_only(monkeypatch, kb_dir): assert saved["model"] == "claude-opus" +def test_kb_config_patch_sets_and_reverts_entity_types(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + # Set a custom vocabulary: stored raw, read back cleaned (lowercased, unsafe + # chars stripped, deduped, "other" ensured) with source 'kb'. + r = client.patch( + "/api/v1/kb/config", + json={"kb": kb, "config": {"entity_types": ["Person", "Team!"]}}, + headers=_auth(), + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["entity_types"] == ["person", "team", "other"] + assert body["sources"]["entity_types"] == "kb" + saved = yaml.safe_load((kb_dir / ".openkb" / "config.yaml").read_text()) + assert saved["entity_types"] == ["Person", "Team!"] # raw value persisted + + # Revert (explicit null) -> inherit; with no global set, falls to default. + r = client.patch( + "/api/v1/kb/config", json={"kb": kb, "config": {"entity_types": None}}, headers=_auth() + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["sources"]["entity_types"] == "default" + assert body["entity_types"][-1] == "other" + + +def test_global_config_patch_entity_types(monkeypatch, tmp_path): + monkeypatch.setattr("openkb.config.GLOBAL_CONFIG_PATH", tmp_path / "global.yaml") + monkeypatch.setattr("openkb.config.GLOBAL_CONFIG_DIR", tmp_path) + monkeypatch.delenv("OPENKB_KB_ROOT", raising=False) + client = _client(monkeypatch) + r = client.patch( + "/api/v1/config", json={"config": {"entity_types": ["Org", "Law!"]}}, headers=_auth() + ) + assert r.status_code == 200, r.text + assert r.json()["entity_types"] == ["org", "law", "other"] # cleaned effective list + + +def test_kb_config_get_reports_global_source_for_entity_types(monkeypatch, kb_dir, tmp_path): + monkeypatch.setattr("openkb.config.GLOBAL_CONFIG_PATH", tmp_path / "global.yaml") + monkeypatch.setattr("openkb.config.GLOBAL_CONFIG_DIR", tmp_path) + from openkb.config import save_global_config + + save_global_config({"entity_types": ["team", "org"]}) + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + body = client.get("/api/v1/kb/config", params={"kb": kb}, headers=_auth()).json() + assert body["sources"]["entity_types"] == "global" # inherited from global.yaml + assert body["entity_types"] == ["team", "org", "other"] + assert body["global_values"]["entity_types"] == ["team", "org"] # raw global for the badge + + def test_kb_config_patch_unknown_field_is_400(monkeypatch, kb_dir): client = _client(monkeypatch) kb = _use_named_kb(monkeypatch, kb_dir) @@ -2477,6 +2531,8 @@ def test_global_config_get_defaults_when_absent(monkeypatch, tmp_path): "model": "gpt-5.4", "language": "en", "pageindex_threshold": 20, + # Default entity-type vocabulary when global.yaml sets none. + "entity_types": ["person", "organization", "place", "product", "work", "event", "other"], # kb_root reports the EFFECTIVE root; with no env/global override it is # the default /kbs, and env_pinned is False. "kb_root": str((tmp_path / "kbs").resolve()),