diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java index e7dcada2..5c24dcfa 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java @@ -84,4 +84,23 @@ public class WikiKbConfig { * lets the extractor use its built-in default type set. */ private List entityTypes; + + /** + * Optional closed relation schema: a whitelist of + * (subjectType, predicate, objectType) triples. When non-empty, + * extraction is constrained to only these relations instead of freely + * inferring arbitrary ones, which keeps the resulting graph focused on + * the handful of relationships a KB actually cares about instead of + * diluting it with incidental entities. {@code null} or empty keeps the + * legacy open-vocabulary behaviour. + */ + private List relationSchema; + + /** One allowed relation triple in {@link #relationSchema}. */ + @Data + public static class RelationSchemaEntry { + private String subjectType; + private String predicate; + private String objectType; + } } 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 6bbdd007..2f1cd507 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 @@ -115,9 +115,10 @@ public class WikiEntityExtractionService { } List types = resolveEntityTypes(kb); + List relationSchema = resolveRelationSchema(kb); BeanOutputConverter converter = new BeanOutputConverter<>(EntityExtractionResult.class); - String systemPrompt = buildSystemPrompt(types); + String systemPrompt = buildSystemPrompt(types, relationSchema); // Per-run resolution cache: type+normalizedKey → entityId. Seeded lazily // from the DB so entities resolve consistently within and across chunks. @@ -144,7 +145,7 @@ public class WikiEntityExtractionService { if (alreadyProcessed) { clearChunkArtifacts(chunk.getId()); } - persistChunk(kbId, chunk, result, resolved, index); + persistChunk(kbId, chunk, result, resolved, index, relationSchema); } catch (Exception e) { log.warn("[WikiEntity] Extraction failed for chunkId={} kbId={}: {}", chunk.getId(), kbId, e.getMessage()); @@ -180,27 +181,44 @@ public class WikiEntityExtractionService { } } - private String buildSystemPrompt(List types) { - return "You are a knowledge-graph entity extractor. From the given source text, " - + "extract named entities and the factual relations between them.\n" - + "Entity types to use: " + String.join(", ", types) + ".\n" - + "Rules:\n" - + "- Only extract entities explicitly named in the text; do not invent any.\n" - + "- Use the most complete surface form as the name; list shorter forms as aliases.\n" - + "- For each relation, subject and object must both appear in the entities list.\n" - + "- Keep predicates short and snake_case (e.g. works_for, located_in, founded).\n" - + "- Provide a short verbatim evidence quote for each entity and relation.\n" - + "- If nothing relevant is present, return empty lists."; + private String buildSystemPrompt(List types, List relationSchema) { + StringBuilder sb = new StringBuilder() + .append("You are a knowledge-graph entity extractor. From the given source text, ") + .append("extract named entities and the factual relations between them.\n") + .append("Entity types to use: ").append(String.join(", ", types)).append(".\n") + .append("Rules:\n") + .append("- Only extract entities explicitly named in the text; do not invent any.\n") + .append("- Use the most complete surface form as the name; list shorter forms as aliases.\n") + .append("- For each relation, subject and object must both appear in the entities list.\n") + .append("- Keep predicates short and snake_case (e.g. works_for, located_in, founded).\n") + .append("- Provide a short verbatim evidence quote for each entity and relation.\n") + .append("- If nothing relevant is present, return empty lists."); + if (relationSchema != null && !relationSchema.isEmpty()) { + sb.append("\n\nAllowed relations (ONLY extract these — ignore everything else):\n"); + for (WikiKbConfig.RelationSchemaEntry rule : relationSchema) { + if (rule == null) { + continue; + } + sb.append("- ").append(rule.getSubjectType()).append(' ') + .append(rule.getPredicate()).append(' ') + .append(rule.getObjectType()).append('\n'); + } + sb.append("Only extract named entities that participate in at least one relation above. ") + .append("Ignore all other named entities and relations, even if they fit one of the entity types."); + } + return sb.toString(); } // ---- persistence ------------------------------------------------------ private void persistChunk(Long kbId, WikiChunkEntity chunk, EntityExtractionResult result, - Map resolved, EntityIndex index) { + Map resolved, EntityIndex index, + List relationSchema) { Long pageId = firstCitingPage(chunk.getId()); // Resolve each entity to a canonical id, persist its mention for this chunk. Map localByName = new HashMap<>(); + Map localTypeByName = new HashMap<>(); if (result.getEntities() != null) { for (EntityExtractionResult.ExtractedEntity e : result.getEntities()) { if (e == null || e.getName() == null || e.getName().isBlank()) { @@ -212,10 +230,12 @@ public class WikiEntityExtractionService { continue; } localByName.put(normalize(e.getName()), entityId); + localTypeByName.put(normalize(e.getName()), type); if (e.getAliases() != null) { for (String alias : e.getAliases()) { if (alias != null && !alias.isBlank()) { localByName.put(normalize(alias), entityId); + localTypeByName.put(normalize(alias), type); } } } @@ -224,7 +244,10 @@ public class WikiEntityExtractionService { } } - // Persist relations whose endpoints both resolved. + // Persist relations whose endpoints both resolved and, when a relation + // schema is configured, whose (subjectType, predicate, objectType) + // matches an allowed triple — a hard backstop in case the model + // doesn't fully follow the prompt-level restriction. if (result.getRelations() != null) { for (EntityExtractionResult.ExtractedRelation r : result.getRelations()) { if (r == null || r.getSubject() == null || r.getObject() == null @@ -236,12 +259,49 @@ public class WikiEntityExtractionService { if (subjectId == null || objectId == null || subjectId.equals(objectId)) { continue; } - upsertRelation(kbId, subjectId, objectId, normalizePredicate(r.getPredicate()), - r.getEvidence(), chunk.getId()); + String predicate = normalizePredicate(r.getPredicate()); + String subjectType = localTypeByName.get(normalize(r.getSubject())); + String objectType = localTypeByName.get(normalize(r.getObject())); + if (!allowedBySchema(relationSchema, subjectType, predicate, objectType)) { + continue; + } + upsertRelation(kbId, subjectId, objectId, predicate, r.getEvidence(), chunk.getId()); } } } + /** + * True when {@code schema} is empty (legacy open behaviour) or contains a + * triple matching {@code subjectType}/{@code predicate}/{@code objectType}. + * {@code predicate} is expected to already be {@link #normalizePredicate}d; + * the rule's predicate is normalized the same way before comparing so + * e.g. a user-entered "works for" matches an extracted "works_for". + */ + private boolean allowedBySchema(List schema, + String subjectType, String predicate, String objectType) { + if (schema == null || schema.isEmpty()) { + return true; + } + for (WikiKbConfig.RelationSchemaEntry rule : schema) { + if (rule == null || rule.getPredicate() == null || rule.getPredicate().isBlank()) { + continue; + } + if (equalsNormalized(rule.getSubjectType(), subjectType) + && normalizePredicate(rule.getPredicate()).equals(predicate) + && equalsNormalized(rule.getObjectType(), objectType)) { + return true; + } + } + return false; + } + + private boolean equalsNormalized(String a, String b) { + if (a == null || b == null) { + return false; + } + return a.trim().equalsIgnoreCase(b.trim()); + } + /** * Resolve an extracted entity to a canonical node id: run cache → exact * key match in DB → embedding near-match → create new. @@ -457,6 +517,16 @@ public class WikiEntityExtractionService { return DEFAULT_ENTITY_TYPES; } + private List resolveRelationSchema(WikiKnowledgeBaseEntity kb) { + if (kb.getConfigContent() != null) { + WikiKbConfig config = WikiKbConfigParser.parse(objectMapper, kb.getConfigContent()); + if (config != null && config.getRelationSchema() != null && !config.getRelationSchema().isEmpty()) { + return config.getRelationSchema(); + } + } + return List.of(); + } + private String normalize(String s) { return s == null ? "" : s.trim().toLowerCase(Locale.ROOT).replaceAll("\\s+", " "); } 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 821ab7c3..b8615579 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 @@ -172,6 +172,46 @@ class WikiEntityExtractionServiceTest { verify(mentionMapper, times(2)).insert(any(WikiEntityMentionEntity.class)); } + @Test + @DisplayName("relation schema: filters out a relation whose triple isn't in the whitelist, entities still persist") + void extractForRaw_relationSchema_filtersNonMatchingRelation() { + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + kb.setId(KB_ID); + // Schema only allows organization→employs→person; the canned LLM output + // is person→works_for→organization, so it must not match. + kb.setConfigContent(""" + {"relationSchema": [{"subjectType": "organization", "predicate": "employs", "objectType": "person"}]} + """); + when(kbService.getById(KB_ID)).thenReturn(kb); + when(chunkService.listByRawId(RAW_ID)).thenReturn(List.of(chunk(1L, "Alice works at Acme."))); + + int touched = service.extractForRaw(KB_ID, RAW_ID); + + assertEquals(2, touched, "entities still resolve even when their relation is filtered out"); + verify(entityMapper, times(2)).insert(any(WikiEntityEntity.class)); + verify(mentionMapper, times(2)).insert(any(WikiEntityMentionEntity.class)); + verify(relationMapper, times(0)).insert(any(WikiEntityRelationEntity.class)); + } + + @Test + @DisplayName("relation schema: persists a relation whose triple matches the whitelist") + void extractForRaw_relationSchema_allowsMatchingRelation() { + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + kb.setId(KB_ID); + // "works for" (with a space) must normalize the same way as the + // extracted "works for" predicate for the match to succeed. + kb.setConfigContent(""" + {"relationSchema": [{"subjectType": "person", "predicate": "works for", "objectType": "organization"}]} + """); + when(kbService.getById(KB_ID)).thenReturn(kb); + when(chunkService.listByRawId(RAW_ID)).thenReturn(List.of(chunk(1L, "Alice works at Acme."))); + + int touched = service.extractForRaw(KB_ID, RAW_ID); + + assertEquals(2, touched); + verify(relationMapper, times(1)).insert(any(WikiEntityRelationEntity.class)); + } + @Test @DisplayName("extractForRaw: skips chunks that already have mentions") void extractForRaw_skipsProcessedChunks() { diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 924f363f..47ad4ea0 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -2739,6 +2739,12 @@ export default { 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).', + relationSchemaLabel: 'Relation schema', + relationSchemaHint: 'Restrict extraction to only these relation triples; other relations, and entities that don\'t participate in any of them, are ignored. Leave empty to keep open-ended relation extraction.', + relationSchemaSubject: 'Subject type', + relationSchemaPredicate: 'Relation (e.g. works_for)', + relationSchemaObject: 'Object type', + relationSchemaAdd: '+ Add relation', modelStrategy: 'Model Strategy', globalDefault: 'Global default', selectModel: 'Select a model…', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index b8786f47..f31dd4bb 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -2751,6 +2751,12 @@ export default { entityTypesLabel: '抽取的实体类型', entityTypesPlaceholder: '选择或输入实体类型,回车添加', entityTypesHint: '限定要抽取的实体类型;留空则使用内置默认类型(人物、组织、地点、事件、产品、概念)。', + relationSchemaLabel: '关系模式', + relationSchemaHint: '限定只抽取以下几条确定的关系三元组,其余关系与不参与这些关系的实体一律忽略;留空则保持开放抽取(模型自行判断关系)。', + relationSchemaSubject: '主体类型', + relationSchemaPredicate: '关系(如 works_for)', + relationSchemaObject: '客体类型', + relationSchemaAdd: '+ 添加一条关系', modelStrategy: '模型策略', globalDefault: '跟随全局默认', selectModel: '选择可用模型…', diff --git a/mateclaw-ui/src/views/Wiki/components/WikiConfig.vue b/mateclaw-ui/src/views/Wiki/components/WikiConfig.vue index f0e20bf3..30b0250b 100644 --- a/mateclaw-ui/src/views/Wiki/components/WikiConfig.vue +++ b/mateclaw-ui/src/views/Wiki/components/WikiConfig.vue @@ -90,6 +90,54 @@
{{ t('wiki.configPanel.entityTypesHint') }}
+ + +
+
{{ t('wiki.configPanel.relationSchemaLabel') }}
+
{{ t('wiki.configPanel.relationSchemaHint') }}
+ +
+ + + + + + + + +
+ + +
@@ -297,6 +345,24 @@ const entityTypes = ref([]) const savingEntityExtraction = ref(false) const extracting = ref(false) +// Optional closed relation schema: a whitelist of (subjectType, predicate, +// objectType) triples. Empty = open-vocabulary relations (legacy behavior). +interface RelationSchemaRow { subjectType: string; predicate: string; objectType: string } +const relationSchema = ref([]) + +// Type dropdown suggestions: whatever entity types are currently configured, +// plus the built-in defaults, deduped. +const relationSchemaTypeOptions = computed(() => + [...new Set([...entityTypes.value, ...DEFAULT_ENTITY_TYPES])]) + +function addRelationSchemaRow() { + relationSchema.value.push({ subjectType: '', predicate: '', objectType: '' }) +} + +function removeRelationSchemaRow(idx: number) { + relationSchema.value.splice(idx, 1) +} + async function saveEntityExtraction() { if (!store.currentKB) return savingEntityExtraction.value = true @@ -312,6 +378,18 @@ async function saveEntityExtraction() { ? [...new Set(entityTypes.value.map(s => s.trim().toLowerCase()).filter(Boolean))] : [] existingConfig.entityTypes = cleanedTypes.length > 0 ? cleanedTypes : undefined + // Only keep fully-filled rows; a row with any blank field can't match + // anything on the backend and would silently do nothing. + const cleanedRelationSchema = entityExtractionEnabled.value + ? relationSchema.value + .map(row => ({ + subjectType: row.subjectType.trim().toLowerCase(), + predicate: row.predicate.trim().toLowerCase(), + objectType: row.objectType.trim().toLowerCase(), + })) + .filter(row => row.subjectType && row.predicate && row.objectType) + : [] + existingConfig.relationSchema = cleanedRelationSchema.length > 0 ? cleanedRelationSchema : undefined await wikiApi.updateConfig(store.currentKB.id, JSON.stringify(existingConfig, null, 2)) } catch (e) { console.error('[WikiConfig] Failed to save entity extraction toggle', e) @@ -352,6 +430,7 @@ function loadStepModels() { ingestMode.value = 'eager' entityExtractionEnabled.value = false entityTypes.value = [] + relationSchema.value = [] if (!store.currentKB) return try { const cfg = store.currentKB.configContent ? JSON.parse(store.currentKB.configContent) : null @@ -366,6 +445,13 @@ function loadStepModels() { if (cfg?.ingestMode === 'lazy') ingestMode.value = 'lazy' if (cfg?.entityExtractionEnabled) entityExtractionEnabled.value = true if (Array.isArray(cfg?.entityTypes)) entityTypes.value = cfg.entityTypes.map(String) + if (Array.isArray(cfg?.relationSchema)) { + relationSchema.value = cfg.relationSchema.map((row: any) => ({ + subjectType: String(row?.subjectType ?? ''), + predicate: String(row?.predicate ?? ''), + objectType: String(row?.objectType ?? ''), + })) + } } catch { /* not JSON */ } } @@ -596,6 +682,38 @@ loadProviderNames().then(() => { .entity-types__select { width: 100%; } .entity-types__hint { font-size: 11px; color: var(--mc-text-tertiary); line-height: 1.4; } +/* Relation schema editor */ +.relation-schema { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; } +.relation-schema__label { font-size: 12px; font-weight: 600; color: var(--mc-text-secondary); } +.relation-schema__hint { font-size: 11px; color: var(--mc-text-tertiary); line-height: 1.4; } +.relation-schema__row { display: flex; align-items: center; gap: 6px; } +.relation-schema__type { flex: 1; min-width: 0; } +.relation-schema__predicate { + flex: 1; + min-width: 0; + height: 24px; + padding: 0 8px; + font-size: 12px; + color: var(--mc-text-primary); + background: var(--mc-bg-sunken); + border: 1px solid var(--mc-border); + border-radius: 4px; +} +.relation-schema__predicate:focus { outline: none; border-color: var(--mc-primary); } +.relation-schema__remove { + flex-shrink: 0; + width: 22px; + height: 22px; + border: 1px solid var(--mc-border); + border-radius: 4px; + background: transparent; + color: var(--mc-text-tertiary); + cursor: pointer; + line-height: 1; +} +.relation-schema__remove:hover { color: var(--mc-text-primary); border-color: var(--mc-text-tertiary); } +.relation-schema__add { align-self: flex-start; margin-top: 2px; } + /* Ingest mode radio group */ .ingest-mode-row { display: flex; gap: 8px; flex-wrap: wrap; } .ingest-mode-option {