Skip to content

Commit ff54396

Browse files
feat(workbench): configurable entity types (global default + per-KB override) (#200)
* 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 * feat(web): entity-types config UI — chips editor (global default + per-KB override) - EntityTypesEditor: shared controlled chips editor (Enter/comma to add, x to remove; "other" is a fixed always-included chip; IME-safe composition). - KbSettingsSheet: an EntityTypesRow with the same inherit/override Switch as the scalar rows — turning override on seeds+persists the KB's own list, off reverts via null; inherited state shows the global/default list as a badge. Each chip change persists and adopts the server-cleaned response. - Settings (general tab): a global entity-types chips editor, order-sensitive diff into the save patch (joins the existing dirty/SaveBar flow). - "changes affect future recompiles only" note on both surfaces. New keys in common/kbSettings/settings (zh + en, identical sets). Build green (i18n guard OK). Backend was committed in 92f8f41. Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG * fix(entity-types): address xhigh review — DEFAULT_CONFIG parity, empty-list inherit, silent reads, set-diff - config: define DEFAULT_ENTITY_TYPES before DEFAULT_CONFIG and add `entity_types` to DEFAULT_CONFIG so the key layers like every other GLOBAL_SCALAR_KEY and always appears in the effective config (#1). - config: resolve_effective_config treats an empty entity_types list the same as null → inherit, so a KB that cleared its override doesn't pin an empty vocabulary (#2). - config: resolve_entity_types(config, *, warn=True); config-read paths pass warn=False so a plain GET doesn't spam coercion warnings (#4). - api_config: both read paths call resolve_entity_types(..., warn=False). - KbSettingsSheet: inherited badge shows the effective list, not `globalValue ?? effective`; drop the now-unused globalValue prop (#3). - Settings: global entity_types diff is order-insensitive — the vocabulary is a set, so re-adding a removed type is not a change (#6). Backend pytest 1240 passed; ruff/format/mypy clean; frontend build green. Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG
1 parent 8b1a569 commit ff54396

15 files changed

Lines changed: 351 additions & 32 deletions

File tree

frontend/src/api/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ export interface GlobalConfig {
66
model: string
77
language: string
88
pageindex_threshold: number
9+
/** CLEANED effective global entity-extraction vocabulary (always includes
10+
* "other"). Mirrors KbConfig.entity_types. */
11+
entity_types: string[]
912
/** Plaintext global-default LLM base URL (a config value, not a secret);
1013
* null if unset. Mirrors KbConfig.openai_api_base. */
1114
openai_api_base?: string | null
@@ -30,6 +33,9 @@ export interface GlobalConfigPatch {
3033
model?: string | null
3134
language?: string | null
3235
pageindex_threshold?: number | null
36+
/** A list sets the global vocabulary; `null` reverts to the built-in
37+
* default. Cleaned server-side (lowercase, `[a-z0-9 _-]`, + "other"). */
38+
entity_types?: string[] | null
3339
}
3440
api_key?: string | null
3541
openai_api_base?: string | null

frontend/src/api/kb.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,17 +54,21 @@ export interface KbConfig {
5454
model: string
5555
language: string
5656
pageindex_threshold: number
57+
/** CLEANED effective entity-extraction vocabulary (always includes "other"). */
58+
entity_types: string[]
5759
/** Plaintext LLM base URL (a config value, not a credential); null if unset. */
5860
openai_api_base: string | null
5961
/** Presence flag only — the raw key value is NEVER returned by the API. */
6062
has_api_key: boolean
61-
/** Which layer supplied each scalar's effective value. */
62-
sources: Record<"model" | "language" | "pageindex_threshold", ConfigSource>
63-
/** Raw global-layer scalars (null where global.yaml is silent). */
63+
/** Which layer supplied each field's effective value. */
64+
sources: Record<"model" | "language" | "pageindex_threshold" | "entity_types", ConfigSource>
65+
/** Raw global-layer values (null where global.yaml is silent). */
6466
global_values: {
6567
model: string | null
6668
language: string | null
6769
pageindex_threshold: number | null
70+
/** RAW global list (not cleaned) — for the inherited badge. */
71+
entity_types: string[] | null
6872
}
6973
}
7074

@@ -76,7 +80,11 @@ export interface KbConfig {
7680
* key" request, not "unchanged" or "clear".
7781
*/
7882
export interface KbConfigPatch {
79-
config?: Partial<Pick<KbConfig, "model" | "language" | "pageindex_threshold">>
83+
config?: Partial<Pick<KbConfig, "model" | "language" | "pageindex_threshold">> & {
84+
/** A list sets/overrides for this KB; `null` reverts to inherited. Values
85+
* are cleaned server-side (lowercase, `[a-z0-9 _-]`, dedupe, + "other"). */
86+
entity_types?: string[] | null
87+
}
8088
api_key?: string | null
8189
openai_api_base?: string | null
8290
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { useState } from 'react'
2+
import { useTranslation } from 'react-i18next'
3+
import { X } from 'lucide-react'
4+
5+
/** The backend's catch-all bucket: it is re-appended server-side on every
6+
* write, so the editor renders it as a fixed, non-removable chip. */
7+
const ALWAYS_INCLUDED = 'other'
8+
9+
/**
10+
* Chips editor for the entity-extraction vocabulary, shared by the per-KB
11+
* override row (KbSettingsSheet) and the global editor (Settings › general).
12+
* Controlled: renders `value` as removable chips + an add input (Enter to add,
13+
* commas split), and calls `onChange` with the FULL next list on each
14+
* add/remove. Client-side it only trims/lowercases and drops empties/dupes —
15+
* the authoritative cleaning (charset, dedupe, "other") happens server-side.
16+
*/
17+
export default function EntityTypesEditor({
18+
value,
19+
disabled,
20+
onChange,
21+
}: {
22+
/** Current list (server-cleaned effective list, or local draft state). */
23+
value: string[]
24+
disabled?: boolean
25+
/** Receives the complete next list after an add/remove. */
26+
onChange: (next: string[]) => void
27+
}) {
28+
const { t } = useTranslation('common')
29+
const [draft, setDraft] = useState('')
30+
31+
const add = () => {
32+
const seen = new Set(value.map((v) => v.toLowerCase()))
33+
const added: string[] = []
34+
for (const part of draft.split(',')) {
35+
const v = part.trim().toLowerCase()
36+
if (v === '' || seen.has(v)) continue
37+
seen.add(v)
38+
added.push(v)
39+
}
40+
setDraft('')
41+
if (added.length > 0) onChange([...value, ...added])
42+
}
43+
44+
return (
45+
<div className="flex flex-wrap items-center gap-1.5 rounded-md border border-input px-2.5 py-2">
46+
{value.map((item) => (
47+
<span
48+
key={item}
49+
className="inline-flex items-center gap-1 rounded-full border border-[hsl(var(--glass-border))] bg-muted px-2.5 py-1 text-[11.5px] font-mono2 text-foreground"
50+
>
51+
{item}
52+
{item === ALWAYS_INCLUDED ? (
53+
<span className="text-[10.5px] text-muted-foreground">{t('entityTypes.always')}</span>
54+
) : (
55+
<button
56+
type="button"
57+
onClick={() => onChange(value.filter((x) => x !== item))}
58+
disabled={disabled}
59+
aria-label={t('entityTypes.removeAria', { type: item })}
60+
className="grid h-3.5 w-3.5 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-60"
61+
>
62+
<X className="h-2.5 w-2.5" />
63+
</button>
64+
)}
65+
</span>
66+
))}
67+
<input
68+
value={draft}
69+
disabled={disabled}
70+
onChange={(e) => setDraft(e.target.value)}
71+
onKeyDown={(e) => {
72+
// Enter confirms IME composition first; only a real Enter adds.
73+
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
74+
e.preventDefault()
75+
add()
76+
}
77+
}}
78+
placeholder={t('entityTypes.placeholder')}
79+
aria-label={t('entityTypes.placeholder')}
80+
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"
81+
/>
82+
</div>
83+
)
84+
}

frontend/src/components/KbSettingsSheet.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { SseEvent } from '@/api/client'
1616
import { Switch } from '@/components/ui/switch'
1717
import { cn } from '@/lib/utils'
1818
import { UnLanguageDatalist, UN_LANG_LIST_ID } from '@/components/UnLanguageDatalist'
19+
import EntityTypesEditor from '@/components/EntityTypesEditor'
1920

2021
const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e))
2122

@@ -169,6 +170,22 @@ function KbConfigSection({ kb }: { kb: string }) {
169170
[kb, apply, t],
170171
)
171172

173+
// Persist the entity-types override (list) or revert to inherited (null).
174+
// Always adopts the response: the row then shows the server-CLEANED list.
175+
const setEntityTypesOverride = useCallback(
176+
async (value: string[] | null) => {
177+
setBusy(true)
178+
try {
179+
apply(await patchKbConfig(kb, { config: { entity_types: value } }))
180+
} catch (e) {
181+
toast.error(t('common:saveError', { error: errMsg(e) }))
182+
} finally {
183+
setBusy(false)
184+
}
185+
},
186+
[kb, apply, t],
187+
)
188+
172189
const saveCredentials = useCallback(async () => {
173190
if (!config) return
174191
setBusy(true)
@@ -258,6 +275,13 @@ function KbConfigSection({ kb }: { kb: string }) {
258275
onSet={(v) => setOverride('pageindex_threshold', Number(v))}
259276
onRevert={() => setOverride('pageindex_threshold', null)}
260277
/>
278+
<EntityTypesRow
279+
source={config.sources.entity_types}
280+
effective={config.entity_types}
281+
busy={busy}
282+
onSet={setEntityTypesOverride}
283+
onRevert={() => setEntityTypesOverride(null)}
284+
/>
261285

262286
<h3 className="pt-2 text-[12px] font-semibold text-muted-foreground tracking-wide">{t('kbSettings:credHeading')}</h3>
263287
<div>
@@ -385,6 +409,64 @@ function OverrideRow({
385409
)
386410
}
387411

412+
/** OverrideRow's list-shaped sibling for the entity-extraction vocabulary:
413+
* the same 为本库覆盖 switch + inherited badge, but the override editor is a
414+
* chips list (EntityTypesEditor) and every add/remove persists immediately —
415+
* the chips always show the server-cleaned effective list. */
416+
function EntityTypesRow({
417+
source, effective, busy, onSet, onRevert,
418+
}: {
419+
source: ConfigSource
420+
/** Cleaned effective list (always includes "other") — shown as-is in the
421+
* inherited badge, so it matches the vocabulary the compiler actually uses. */
422+
effective: string[]
423+
busy: boolean
424+
onSet: (value: string[]) => void
425+
onRevert: () => void
426+
}) {
427+
const { t } = useTranslation(['kbSettings', 'common'])
428+
const overridden = source === 'kb'
429+
const label = t('common:fields.entityTypes')
430+
431+
const inheritedBadge =
432+
source === 'global'
433+
? t('kbSettings:inheritGlobal', { value: effective.join(', ') })
434+
: t('kbSettings:inheritDefault', { value: effective.join(', ') })
435+
436+
return (
437+
<div>
438+
<div className="flex items-center justify-between">
439+
<label className="text-[12px] font-medium text-muted-foreground">{label}</label>
440+
<span className="flex items-center gap-2 text-[11px] text-muted-foreground">
441+
{t('kbSettings:override')}
442+
<Switch
443+
checked={overridden}
444+
disabled={busy}
445+
// ON seeds the override with the current effective list (the
446+
// backend then owns it as this KB's own list); OFF reverts to
447+
// inherited via an explicit null.
448+
onCheckedChange={(v) => (v ? onSet(effective) : onRevert())}
449+
aria-label={t('kbSettings:overrideAria', { label })}
450+
/>
451+
</span>
452+
</div>
453+
{overridden ? (
454+
<div className="mt-1.5">
455+
<EntityTypesEditor value={effective} disabled={busy} onChange={onSet} />
456+
</div>
457+
) : (
458+
<div
459+
data-field="entity_types"
460+
className="mt-1.5 flex min-h-9 items-center rounded-md border border-dashed border-[hsl(var(--glass-border))] px-3 py-1.5 text-[12.5px] text-muted-foreground"
461+
>
462+
{inheritedBadge}
463+
</div>
464+
)}
465+
<p className="mt-1.5 text-[11.5px] text-muted-foreground">{t('kbSettings:entityTypesNote')}</p>
466+
</div>
467+
)
468+
}
469+
388470
/** Per-doc row accumulated from a recompile SSE stream (from the `doc` event).
389471
* Moved verbatim from `pages/KbDetail.tsx` (formerly its 任务 tab). */
390472
interface RecompileDoc {

frontend/src/locales/en/common.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@
1313
"fields": {
1414
"model": "Model",
1515
"wikiLanguage": "Wiki output language",
16-
"threshold": "PageIndex threshold (pages)"
16+
"threshold": "PageIndex threshold (pages)",
17+
"entityTypes": "Entity types"
18+
},
19+
"entityTypes": {
20+
"placeholder": "Add a type, press Enter",
21+
"removeAria": "Remove type {{type}}",
22+
"always": "always included"
1723
},
1824
"errors": {
1925
"requestFailed": "Request failed",

frontend/src/locales/en/kbSettings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"inheritDefault": "Inherited · default ({{value}})",
1919
"override": "Override for this KB",
2020
"overrideAria": "Override {{label}} for this KB",
21+
"entityTypesNote": "Changes apply to future recompiles only; existing entity pages keep their current type.",
2122
"unnamed": "(unnamed)",
2223
"recompileFailed": "Recompile failed",
2324
"watchStarted": "File watching started",

frontend/src/locales/en/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
},
2222
"general": {
2323
"langDesc": "The output language written into the compile prompt, e.g. en / 中文 / 日本語",
24+
"entityTypesDesc": "The vocabulary used to classify entities extracted at compile time; each KB can inherit or override it in its own settings.",
25+
"entityTypesNote": "Changes apply to future recompiles only; existing entity pages keep their current type.",
2426
"kbRootLabel": "Knowledge base root",
2527
"kbRootDesc": "Default location for new knowledge bases (<root>/<name>); leave empty to restore the built-in default. Individual KBs can set a custom path at creation.",
2628
"kbRootPlaceholder": "e.g. ~/openkb",

frontend/src/locales/zh/common.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@
1313
"fields": {
1414
"model": "模型",
1515
"wikiLanguage": "Wiki 输出语言",
16-
"threshold": "PageIndex 阈值(页数)"
16+
"threshold": "PageIndex 阈值(页数)",
17+
"entityTypes": "实体类型"
18+
},
19+
"entityTypes": {
20+
"placeholder": "输入类型,按 Enter 添加",
21+
"removeAria": "移除类型 {{type}}",
22+
"always": "始终包含"
1723
},
1824
"errors": {
1925
"requestFailed": "请求失败",

frontend/src/locales/zh/kbSettings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"inheritDefault": "继承 · 默认({{value}})",
1919
"override": "为本库覆盖",
2020
"overrideAria": "{{label}} 为本库覆盖",
21+
"entityTypesNote": "改动仅影响之后的重新编译;已有实体页会保留其当前类型。",
2122
"unnamed": "(未命名)",
2223
"recompileFailed": "重新编译失败",
2324
"watchStarted": "已启动文件监听",

frontend/src/locales/zh/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
},
2222
"general": {
2323
"langDesc": "写入编译 prompt 的输出语言,例如 en / 中文 / 日本語",
24+
"entityTypesDesc": "编译时为抽取出的实体分类所用的类型词表;各知识库可在其设置中继承或覆盖。",
25+
"entityTypesNote": "改动仅影响之后的重新编译;已有实体页会保留其当前类型。",
2426
"kbRootLabel": "知识库根目录",
2527
"kbRootDesc": "新建知识库的默认位置(<根目录>/<名称>);留空则恢复内置默认值。单个知识库可在创建时设置自定义路径。",
2628
"kbRootPlaceholder": "例如 ~/openkb",

0 commit comments

Comments
 (0)