feat(wiki): configurable entity types, type legend filter & theme-aligned graph colors (#336)

- per-KB entity-type whitelist (config UI + persistence; empty = built-in defaults)
- entity graph: legend grouped by type with click-to-filter; nodes colored by type
- always show entity names on graph nodes (not only on hover)
- earthy categorical palette aligned to the app theme, shared by entity & page graphs
- theme-aware graph label color (resolve CSS var for canvas, light/dark correct)
- manual extract = full rebuild: idempotent force re-extraction + orphan pruning,
  guarded against data loss on a fully-failed run
- regression test for force re-extraction; zh/en i18n
This commit is contained in:
matevip 2026-06-19 07:18:09 +08:00
parent 31c98e923d
commit 4804954ad2
8 changed files with 292 additions and 28 deletions

View File

@ -31,9 +31,11 @@ import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* Extracts a named-entity knowledge graph from source chunks: it pulls
@ -90,7 +92,15 @@ public class WikiEntityExtractionService {
* chunks already processed
*/
public int extractForKb(Long kbId, boolean force) {
return extract(kbId, chunkService.listByKbId(kbId), force);
int touched = extract(kbId, chunkService.listByKbId(kbId), force);
if (force && touched > 0) {
// A forced full rebuild may have dropped entity types from the KB
// config; entities that no longer earn a mention become orphans.
// Skip pruning when the run resolved nothing (e.g. the model was
// unavailable) so a total failure can't wipe the existing graph.
pruneOrphanEntities(kbId);
}
return touched;
}
private int extract(Long kbId, List<WikiChunkEntity> chunks, boolean force) {
@ -119,7 +129,8 @@ public class WikiEntityExtractionService {
if (chunk.getContent() == null || chunk.getContent().isBlank()) {
continue;
}
if (!force && hasMentions(chunk.getId())) {
boolean alreadyProcessed = hasMentions(chunk.getId());
if (alreadyProcessed && !force) {
continue;
}
try {
@ -127,6 +138,12 @@ public class WikiEntityExtractionService {
if (result == null) {
continue;
}
// Forced re-extraction: only now that we have a fresh result do
// we wipe the chunk's prior mentions/relations, so a failed LLM
// call leaves the existing graph intact instead of destroying it.
if (alreadyProcessed) {
clearChunkArtifacts(chunk.getId());
}
persistChunk(kbId, chunk, result, resolved, index);
} catch (Exception e) {
log.warn("[WikiEntity] Extraction failed for chunkId={} kbId={}: {}",
@ -335,6 +352,71 @@ public class WikiEntityExtractionService {
return count != null && count > 0;
}
/**
* Remove a chunk's previously-extracted mentions and relations and roll
* back the affected entities' mention counts so a forced re-extraction is
* idempotent. Entities themselves are kept here; orphans (those left with
* zero mentions after a full rebuild) are swept by {@link #pruneOrphanEntities}.
*/
private void clearChunkArtifacts(Long chunkId) {
List<WikiEntityMentionEntity> existing = mentionMapper.selectList(
new LambdaQueryWrapper<WikiEntityMentionEntity>()
.eq(WikiEntityMentionEntity::getChunkId, chunkId));
Set<Long> affected = new HashSet<>();
for (WikiEntityMentionEntity m : existing) {
if (m.getEntityId() != null) {
affected.add(m.getEntityId());
}
}
mentionMapper.delete(new LambdaQueryWrapper<WikiEntityMentionEntity>()
.eq(WikiEntityMentionEntity::getChunkId, chunkId));
relationMapper.delete(new LambdaQueryWrapper<WikiEntityRelationEntity>()
.eq(WikiEntityRelationEntity::getEvidenceChunkId, chunkId));
// Recompute each affected entity's mention count from the surviving
// (non-deleted) rows rather than decrementing, so counts stay exact.
for (Long entityId : affected) {
WikiEntityEntity e = entityMapper.selectById(entityId);
if (e == null) {
continue;
}
Long remaining = mentionMapper.selectCount(new LambdaQueryWrapper<WikiEntityMentionEntity>()
.eq(WikiEntityMentionEntity::getEntityId, entityId));
int count = remaining == null ? 0 : remaining.intValue();
e.setMentionCount(count);
e.setSalience(BigDecimal.valueOf((double) count / (count + 5.0))
.setScale(4, RoundingMode.HALF_UP));
entityMapper.updateById(e);
}
}
/**
* Delete entities in a KB that have no mentions left (and the relations that
* referenced them). Runs after a forced full rebuild so entity types removed
* from the KB config stop appearing in the graph.
*/
private void pruneOrphanEntities(Long kbId) {
List<WikiEntityEntity> orphans = entityMapper.selectList(
new LambdaQueryWrapper<WikiEntityEntity>()
.eq(WikiEntityEntity::getKbId, kbId)
.and(w -> w.isNull(WikiEntityEntity::getMentionCount)
.or().le(WikiEntityEntity::getMentionCount, 0)));
if (orphans == null || orphans.isEmpty()) {
return;
}
Set<Long> ids = new HashSet<>();
for (WikiEntityEntity e : orphans) {
ids.add(e.getId());
}
relationMapper.delete(new LambdaQueryWrapper<WikiEntityRelationEntity>()
.eq(WikiEntityRelationEntity::getKbId, kbId)
.and(w -> w.in(WikiEntityRelationEntity::getSubjectEntityId, ids)
.or().in(WikiEntityRelationEntity::getObjectEntityId, ids)));
entityMapper.delete(new LambdaQueryWrapper<WikiEntityEntity>()
.eq(WikiEntityEntity::getKbId, kbId)
.in(WikiEntityEntity::getId, ids));
log.info("[WikiEntity] Pruned {} orphan entities for kbId={}", ids.size(), kbId);
}
private Long firstCitingPage(Long chunkId) {
List<Long> pages = citationMapper.listPageIdsByChunkId(chunkId);
return (pages == null || pages.isEmpty()) ? null : pages.get(0);

View File

@ -148,6 +148,30 @@ class WikiEntityExtractionServiceTest {
verify(relationMapper, times(2)).insert(any(WikiEntityRelationEntity.class));
}
@Test
@DisplayName("extractForKb(force): clears a chunk's prior mentions/relations before re-extracting")
void extractForKb_forceClearsBeforeReextract() {
when(chunkService.listByKbId(KB_ID)).thenReturn(List.of(chunk(1L, "Alice works at Acme.")));
// Chunk already processed hasMentions() is true, so the force path runs
// its clear step instead of skipping.
when(mentionMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
// One stale mention exists for this chunk, pointing at an entity not in
// the store (so the count-recompute loop simply skips it).
WikiEntityMentionEntity stale = new WikiEntityMentionEntity();
stale.setEntityId(999L);
stale.setChunkId(1L);
when(mentionMapper.selectList(any())).thenReturn(List.of(stale));
int touched = service.extractForKb(KB_ID, true);
assertEquals(2, touched, "should re-resolve both entities on a forced rebuild");
// Stale artifacts wiped exactly once before re-extraction.
verify(mentionMapper, times(1)).delete(any());
verify(relationMapper, times(1)).delete(any());
// Fresh mentions re-inserted (2 entities), not duplicated on top of the old ones.
verify(mentionMapper, times(2)).insert(any(WikiEntityMentionEntity.class));
}
@Test
@DisplayName("extractForRaw: skips chunks that already have mentions")
void extractForRaw_skipsProcessedChunks() {

View File

@ -9,27 +9,30 @@ import { useWikiStore } from '@/stores/useWikiStore'
* built-in ten types.
*/
// Fixed colours for the built-in ten types so existing KBs look unchanged.
// Earthy categorical palette tuned to the warm terracotta + teal app theme
// (see main.css design tokens). Hues are mutually contrasting yet share an
// earthy register so the page graph, sidebar and toolbar feel of-a-piece with
// the rest of the UI. Shared with the entity graph for one visual language.
const BUILTIN_COLORS: Record<string, string> = {
concept: '#D96E46',
person: '#5B8DEF',
place: '#4CAF82',
event: '#F59E0B',
technology: '#8B5CF6',
organization: '#EC4899',
product: '#14B8A6',
term: '#6B7280',
process: '#F97316',
other: '#9CA3AF',
concept: '#6B9A55', // sage
person: '#D97757', // terracotta (theme primary)
place: '#E0A030', // goldenrod
event: '#9B5E8E', // plum
technology: '#4FA39B', // aqua
organization: '#2F8F83', // teal (theme accent)
product: '#5B7DB1', // denim blue
term: '#A07B5C', // taupe
process: '#C08A4E', // ochre
other: '#9C8576', // warm grey
}
// Palette for custom / synthesis types not in the built-in map. A type name is
// hashed to a stable index so the same type always gets the same colour within
// and across sessions, without needing a colour field on the profile schema.
// Same earthy family so generated types stay on-theme.
const HASH_PALETTE = [
'#0EA5E9', '#A855F7', '#22C55E', '#EAB308', '#EF4444',
'#06B6D4', '#D946EF', '#84CC16', '#F43F5E', '#3B82F6',
'#10B981', '#FB923C',
'#C0533F', '#B06E7C', '#8B934A', '#A07B5C', '#5B7DB1', '#9B5E8E',
'#4FA39B', '#C08A4E', '#6B9A55', '#2F8F83', '#D97757', '#E0A030',
]
function hashIndex(s: string, mod: number): number {

View File

@ -2497,6 +2497,9 @@ export default {
entityExtractionEnable: 'Enable entity extraction',
entityExtractionRun: 'Extract now',
entityExtractionRunning: 'Extracting…',
entityTypesLabel: 'Entity types to extract',
entityTypesPlaceholder: 'Select or type an entity type, Enter to add',
entityTypesHint: 'Restrict which entity types are extracted; leave empty to use the built-in defaults (person, organization, location, event, product, concept).',
modelStrategy: 'Model Strategy',
globalDefault: 'Global default',
selectModel: 'Select a model…',
@ -2559,6 +2562,18 @@ export default {
synthesis: 'Synthesis',
other: 'Other',
},
entityTypes: {
person: 'Person',
organization: 'Organization',
location: 'Location',
place: 'Place',
event: 'Event',
product: 'Product',
concept: 'Concept',
technology: 'Technology',
term: 'Term',
other: 'Other',
},
graph: {
tab: 'Knowledge Graph',
nodes: 'nodes',

View File

@ -2509,6 +2509,9 @@ export default {
entityExtractionEnable: '开启实体抽取',
entityExtractionRun: '立即抽取',
entityExtractionRunning: '抽取中…',
entityTypesLabel: '抽取的实体类型',
entityTypesPlaceholder: '选择或输入实体类型,回车添加',
entityTypesHint: '限定要抽取的实体类型;留空则使用内置默认类型(人物、组织、地点、事件、产品、概念)。',
modelStrategy: '模型策略',
globalDefault: '跟随全局默认',
selectModel: '选择可用模型…',
@ -2571,6 +2574,18 @@ export default {
synthesis: '综合',
other: '其他',
},
entityTypes: {
person: '人物',
organization: '组织',
location: '地点',
place: '地点',
event: '事件',
product: '产品',
concept: '概念',
technology: '技术',
term: '术语',
other: '其他',
},
graph: {
tab: '知识图谱',
nodes: '节点',

View File

@ -71,6 +71,25 @@
{{ extracting ? t('wiki.configPanel.entityExtractionRunning') : t('wiki.configPanel.entityExtractionRun') }}
</button>
</div>
<!-- Entity types to extract (whitelist). Empty = use built-in defaults. -->
<div v-if="entityExtractionEnabled" class="entity-types">
<div class="entity-types__label">{{ t('wiki.configPanel.entityTypesLabel') }}</div>
<el-select
v-model="entityTypes"
multiple
filterable
allow-create
default-first-option
:reserve-keyword="false"
size="small"
class="entity-types__select"
:placeholder="t('wiki.configPanel.entityTypesPlaceholder')"
>
<el-option v-for="opt in DEFAULT_ENTITY_TYPES" :key="opt" :label="formatEntityType(opt)" :value="opt" />
</el-select>
<div class="entity-types__hint">{{ t('wiki.configPanel.entityTypesHint') }}</div>
</div>
</div>
<!-- Model strategy -->
@ -186,9 +205,22 @@ import WikiModelPicker, { type ModelOption } from './WikiModelPicker.vue'
import WikiConfigRules from './WikiConfigRules.vue'
import WikiConfigModels from './WikiConfigModels.vue'
const { t } = useI18n()
const { t, te } = useI18n()
const store = useWikiStore()
// Built-in entity types offered as suggestions; users may add custom ones.
// Empty config falls back to the backend's default set.
const DEFAULT_ENTITY_TYPES = ['person', 'organization', 'location', 'event', 'product', 'concept']
// Localized label for an entity type in the suggestion dropdown.
function formatEntityType(type: string): string {
const key = (type || '').toLowerCase()
if (!key) return ''
const i18nKey = `wiki.entityTypes.${key}`
if (te(i18nKey)) return `${t(i18nKey)} (${key})`
return key
}
// Rules state
const configContent = ref('')
const rulesOpen = ref(false)
@ -261,6 +293,7 @@ async function saveIngestMode() {
// Opt-in named-entity knowledge graph extraction. Off by default because it
// adds an LLM call per chunk on top of the page pipeline.
const entityExtractionEnabled = ref(false)
const entityTypes = ref<string[]>([])
const savingEntityExtraction = ref(false)
const extracting = ref(false)
@ -273,6 +306,12 @@ async function saveEntityExtraction() {
if (store.currentKB.configContent) existingConfig = JSON.parse(store.currentKB.configContent)
} catch { /* config may be plain text rules */ }
existingConfig.entityExtractionEnabled = entityExtractionEnabled.value ? true : undefined
// Normalize to trimmed lowercase keys so they match the extractor's type
// normalization; empty list drops the field and uses backend defaults.
const cleanedTypes = entityExtractionEnabled.value
? [...new Set(entityTypes.value.map(s => s.trim().toLowerCase()).filter(Boolean))]
: []
existingConfig.entityTypes = cleanedTypes.length > 0 ? cleanedTypes : undefined
await wikiApi.updateConfig(store.currentKB.id, JSON.stringify(existingConfig, null, 2))
} catch (e) {
console.error('[WikiConfig] Failed to save entity extraction toggle', e)
@ -285,7 +324,9 @@ async function runExtraction() {
if (!store.currentKB) return
extracting.value = true
try {
await wikiApi.extractEntities(store.currentKB.id)
// Manual trigger is a full rebuild (force): re-process every chunk so the
// current entity-type config takes effect even on already-extracted KBs.
await wikiApi.extractEntities(store.currentKB.id, true)
} catch (e) {
console.error('[WikiConfig] Failed to start entity extraction', e)
} finally {
@ -310,6 +351,7 @@ function loadStepModels() {
wikiGlobalModelId.value = ''
ingestMode.value = 'eager'
entityExtractionEnabled.value = false
entityTypes.value = []
if (!store.currentKB) return
try {
const cfg = store.currentKB.configContent ? JSON.parse(store.currentKB.configContent) : null
@ -323,6 +365,7 @@ function loadStepModels() {
if (cfg?.wikiDefaultModelId) wikiGlobalModelId.value = String(cfg.wikiDefaultModelId)
if (cfg?.ingestMode === 'lazy') ingestMode.value = 'lazy'
if (cfg?.entityExtractionEnabled) entityExtractionEnabled.value = true
if (Array.isArray(cfg?.entityTypes)) entityTypes.value = cfg.entityTypes.map(String)
} catch { /* not JSON */ }
}
@ -547,6 +590,12 @@ loadProviderNames().then(() => {
.entity-toggle { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--mc-text-primary); cursor: pointer; }
.entity-toggle__label { user-select: none; }
/* Entity types editor */
.entity-types { display: flex; flex-direction: column; gap: 6px; }
.entity-types__label { font-size: 12px; font-weight: 600; color: var(--mc-text-secondary); }
.entity-types__select { width: 100%; }
.entity-types__hint { font-size: 11px; color: var(--mc-text-tertiary); line-height: 1.4; }
/* Ingest mode radio group */
.ingest-mode-row { display: flex; gap: 8px; flex-wrap: wrap; }
.ingest-mode-option {

View File

@ -7,7 +7,7 @@
<button class="entity-panel__close" @click="selected = null">×</button>
<h3 class="entity-panel__title">{{ selected.canonicalName }}</h3>
<span class="entity-panel__type" :style="{ background: typeColor(selected.type) }">
{{ selected.type }}
{{ formatType(selected.type) }}
</span>
<span class="entity-panel__count">{{ selected.mentionCount || 0 }} {{ t('wiki.graph.mentions') }}</span>
@ -61,7 +61,7 @@ import { wikiApi } from '@/api'
echarts.use([GraphChart, TooltipComponent, LegendComponent, CanvasRenderer])
const { t } = useI18n()
const { t, te } = useI18n()
interface EntityNode {
id: number | string
@ -95,12 +95,47 @@ const selected = ref<EntityNode | null>(null)
const egoEdges = ref<{ predicate: string; label: string }[]>([])
const egoPages = ref<{ pageId: number | string; slug: string; title: string }[]>([])
// Stable color per entity type via a small hash palette.
const PALETTE = ['#5b8ff9', '#5ad8a6', '#f6bd16', '#e8684a', '#6dc8ec', '#9270ca', '#ff9d4d', '#269a99']
// Earthy categorical palette tuned to the warm terracotta + teal app theme
// (see main.css design tokens). Each common entity type gets a fixed, mutually
// contrasting hue person (terracotta) vs product (denim) reads as a clear
// warm/cool split rather than the near-identical blues used before. All hues
// sit at mid lightness so they stay legible on both the light and dark canvas.
const ENTITY_TYPE_COLORS: Record<string, string> = {
person: '#D97757', // terracotta (theme primary)
organization: '#2F8F83', // teal (theme accent)
location: '#E0A030', // goldenrod
place: '#E0A030',
event: '#9B5E8E', // plum
product: '#5B7DB1', // denim blue
concept: '#6B9A55', // sage
technology: '#4FA39B', // aqua
term: '#C0533F', // brick
other: '#9C8576', // warm taupe
}
// Stable fallback palette (same earthy family) for user-defined types.
const FALLBACK_PALETTE = ['#C08A4E', '#B06E7C', '#8B934A', '#A07B5C', '#5B7DB1', '#9B5E8E', '#4FA39B', '#C0533F']
function typeColor(type: string): string {
const key = (type || 'other').toLowerCase()
const fixed = ENTITY_TYPE_COLORS[key]
if (fixed) return fixed
let h = 0
for (let i = 0; i < (type || '').length; i++) h = (h * 31 + type.charCodeAt(i)) >>> 0
return PALETTE[h % PALETTE.length]
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0
return FALLBACK_PALETTE[h % FALLBACK_PALETTE.length]
}
// Resolve a CSS custom property to its computed value so ECharts (canvas, which
// can't read CSS vars) picks up the active light/dark theme color.
function cssVar(name: string, fallback: string): string {
if (typeof document === 'undefined') return fallback
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
return v || fallback
}
// Localized label for an entity type, fallback to capitalized raw key.
function formatType(type: string): string {
const key = (type || '').toLowerCase()
if (!key) return ''
const i18nKey = `wiki.entityTypes.${key}`
if (te(i18nKey)) return t(i18nKey)
return key.charAt(0).toUpperCase() + key.slice(1)
}
async function load() {
@ -123,14 +158,34 @@ function buildOption() {
// Keep IDs as strings throughout backend issues Snowflake IDs that lose
// precision if coerced to Number.
const idSet = new Set(nodes.value.map(n => String(n.id)))
// Distinct entity types present ECharts categories. The category index
// drives each node's color and powers the legend: clicking a type in the
// legend filters its nodes (and their edges) in or out of the graph.
const types: string[] = []
for (const n of nodes.value) {
const tp = (n.type || 'other').toLowerCase()
if (!types.includes(tp)) types.push(tp)
}
types.sort()
const typeIndex = new Map(types.map((tp, i) => [tp, i]))
const categories = types.map(tp => ({
name: formatType(tp),
itemStyle: { color: typeColor(tp) },
}))
const labelColor = cssVar('--mc-text-secondary', '#665245')
const legendColor = cssVar('--mc-text-tertiary', '#9b7d6c')
const nodeList = nodes.value.map(n => {
const size = Math.max(12, Math.min(46, 12 + (n.mentionCount || 0) * 3))
return {
id: String(n.id),
name: n.canonicalName,
symbolSize: size,
itemStyle: { color: typeColor(n.type) },
label: { show: size > 22, position: 'right' as const, fontSize: 10, color: 'var(--mc-text-secondary)', distance: 4 },
category: typeIndex.get((n.type || 'other').toLowerCase()) ?? 0,
// Always show the entity name (not just on hover); larger/more-mentioned
// nodes get a slightly bigger label so hubs stand out.
label: { show: true, position: 'right' as const, fontSize: size > 26 ? 12 : 10, color: labelColor, distance: 5 },
}
})
const edgeList = edges.value
@ -144,6 +199,17 @@ function buildOption() {
return {
backgroundColor: 'transparent',
legend: [{
top: 8,
left: 'center',
type: 'scroll',
itemWidth: 10,
itemHeight: 10,
itemGap: 12,
icon: 'circle',
textStyle: { fontSize: 11, color: legendColor },
data: categories.map(c => c.name),
}],
tooltip: {
trigger: 'item',
formatter: (params: any) => {
@ -152,13 +218,14 @@ function buildOption() {
if (!node) return ''
const desc = (node.description || '').substring(0, 80)
return `<div style="max-width:220px;white-space:normal"><strong>${node.canonicalName}</strong>`
+ `<small style="color:#999;display:block">${node.type}</small>`
+ `<small style="color:#999;display:block">${formatType(node.type)}</small>`
+ (desc ? `<span style="font-size:11px">${desc}</span>` : '') + '</div>'
},
},
series: [{
type: 'graph',
layout: 'force',
categories,
data: nodeList,
links: edgeList,
roam: true,

View File

@ -87,6 +87,14 @@ const kbId = computed<string | number | null>(() => {
})
// Resolve a CSS custom property to its computed value so ECharts (canvas, which
// can't read CSS vars) renders labels in the active light/dark theme color.
function cssVar(name: string, fallback: string): string {
if (typeof document === 'undefined') return fallback
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
return v || fallback
}
// Parse outgoing links JSON string slug[]
function parseLinks(outgoingLinks: string | null | undefined): string[] {
if (!outgoingLinks) return []
@ -209,6 +217,7 @@ const selectedNodeLinks = computed(() => {
})
function buildOption() {
const labelColor = cssVar('--mc-text-secondary', '#665245')
const nodeSet = new Set(nodes.value.map(p => p.slug))
const nodeList = nodes.value.map(p => {
const outDeg = parseLinks(p.outgoingLinks).filter(l => {
@ -227,7 +236,7 @@ function buildOption() {
show: size > 22,
position: 'right' as const,
fontSize: 10,
color: 'var(--mc-text-secondary)',
color: labelColor,
distance: 4,
},
// Do NOT embed Vue reactive proxies here ECharts normalizes data and strips them.