diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEntityExtractionService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEntityExtractionService.java index 9ead0e72..6bbdd007 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEntityExtractionService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEntityExtractionService.java @@ -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 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 existing = mentionMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiEntityMentionEntity::getChunkId, chunkId)); + Set affected = new HashSet<>(); + for (WikiEntityMentionEntity m : existing) { + if (m.getEntityId() != null) { + affected.add(m.getEntityId()); + } + } + mentionMapper.delete(new LambdaQueryWrapper() + .eq(WikiEntityMentionEntity::getChunkId, chunkId)); + relationMapper.delete(new LambdaQueryWrapper() + .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() + .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 orphans = entityMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiEntityEntity::getKbId, kbId) + .and(w -> w.isNull(WikiEntityEntity::getMentionCount) + .or().le(WikiEntityEntity::getMentionCount, 0))); + if (orphans == null || orphans.isEmpty()) { + return; + } + Set ids = new HashSet<>(); + for (WikiEntityEntity e : orphans) { + ids.add(e.getId()); + } + relationMapper.delete(new LambdaQueryWrapper() + .eq(WikiEntityRelationEntity::getKbId, kbId) + .and(w -> w.in(WikiEntityRelationEntity::getSubjectEntityId, ids) + .or().in(WikiEntityRelationEntity::getObjectEntityId, ids))); + entityMapper.delete(new LambdaQueryWrapper() + .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 pages = citationMapper.listPageIdsByChunkId(chunkId); return (pages == null || pages.isEmpty()) ? null : pages.get(0); diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiEntityExtractionServiceTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiEntityExtractionServiceTest.java index 9bfffde2..821ab7c3 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiEntityExtractionServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiEntityExtractionServiceTest.java @@ -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() { diff --git a/mateclaw-ui/src/composables/useWikiPageType.ts b/mateclaw-ui/src/composables/useWikiPageType.ts index cf146f34..9f121ac3 100644 --- a/mateclaw-ui/src/composables/useWikiPageType.ts +++ b/mateclaw-ui/src/composables/useWikiPageType.ts @@ -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 = { - 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 { diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 17f1a478..ef7046c0 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 05ffc987..d9a8d721 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -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: '节点', diff --git a/mateclaw-ui/src/views/Wiki/components/WikiConfig.vue b/mateclaw-ui/src/views/Wiki/components/WikiConfig.vue index b2babbfb..f0e20bf3 100644 --- a/mateclaw-ui/src/views/Wiki/components/WikiConfig.vue +++ b/mateclaw-ui/src/views/Wiki/components/WikiConfig.vue @@ -71,6 +71,25 @@ {{ extracting ? t('wiki.configPanel.entityExtractionRunning') : t('wiki.configPanel.entityExtractionRun') }} + + +
+
{{ t('wiki.configPanel.entityTypesLabel') }}
+ + + +
{{ t('wiki.configPanel.entityTypesHint') }}
+
@@ -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([]) 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 { diff --git a/mateclaw-ui/src/views/Wiki/components/WikiEntityGraphView.vue b/mateclaw-ui/src/views/Wiki/components/WikiEntityGraphView.vue index 58e639ea..6558616b 100644 --- a/mateclaw-ui/src/views/Wiki/components/WikiEntityGraphView.vue +++ b/mateclaw-ui/src/views/Wiki/components/WikiEntityGraphView.vue @@ -7,7 +7,7 @@

{{ selected.canonicalName }}

- {{ selected.type }} + {{ formatType(selected.type) }} {{ selected.mentionCount || 0 }} {{ t('wiki.graph.mentions') }} @@ -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(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 = { + 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 `
${node.canonicalName}` - + `${node.type}` + + `${formatType(node.type)}` + (desc ? `${desc}` : '') + '
' }, }, series: [{ type: 'graph', layout: 'force', + categories, data: nodeList, links: edgeList, roam: true, diff --git a/mateclaw-ui/src/views/Wiki/components/WikiGraphView.vue b/mateclaw-ui/src/views/Wiki/components/WikiGraphView.vue index 54963cbd..d041da99 100644 --- a/mateclaw-ui/src/views/Wiki/components/WikiGraphView.vue +++ b/mateclaw-ui/src/views/Wiki/components/WikiGraphView.vue @@ -87,6 +87,14 @@ const kbId = computed(() => { }) +// 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.