mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
feat(wiki): closed relation schema for entity extraction
Entity extraction previously constrained entity types but let the model freely invent any relation between entities, producing noise that diluted the entities a knowledge base actually cares about. Adds an optional per-KB relation schema (subjectType/predicate/ objectType triples): when set, the extraction prompt is scoped to only those relations, and a hard filter drops anything that slips through before it is persisted. Empty/unset keeps the existing open-vocabulary behaviour.
This commit is contained in:
parent
7a1d237de4
commit
22a61e6e78
@ -84,4 +84,23 @@ public class WikiKbConfig {
|
|||||||
* lets the extractor use its built-in default type set.
|
* lets the extractor use its built-in default type set.
|
||||||
*/
|
*/
|
||||||
private List<String> entityTypes;
|
private List<String> 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<RelationSchemaEntry> relationSchema;
|
||||||
|
|
||||||
|
/** One allowed relation triple in {@link #relationSchema}. */
|
||||||
|
@Data
|
||||||
|
public static class RelationSchemaEntry {
|
||||||
|
private String subjectType;
|
||||||
|
private String predicate;
|
||||||
|
private String objectType;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -115,9 +115,10 @@ public class WikiEntityExtractionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<String> types = resolveEntityTypes(kb);
|
List<String> types = resolveEntityTypes(kb);
|
||||||
|
List<WikiKbConfig.RelationSchemaEntry> relationSchema = resolveRelationSchema(kb);
|
||||||
BeanOutputConverter<EntityExtractionResult> converter =
|
BeanOutputConverter<EntityExtractionResult> converter =
|
||||||
new BeanOutputConverter<>(EntityExtractionResult.class);
|
new BeanOutputConverter<>(EntityExtractionResult.class);
|
||||||
String systemPrompt = buildSystemPrompt(types);
|
String systemPrompt = buildSystemPrompt(types, relationSchema);
|
||||||
|
|
||||||
// Per-run resolution cache: type+normalizedKey → entityId. Seeded lazily
|
// Per-run resolution cache: type+normalizedKey → entityId. Seeded lazily
|
||||||
// from the DB so entities resolve consistently within and across chunks.
|
// from the DB so entities resolve consistently within and across chunks.
|
||||||
@ -144,7 +145,7 @@ public class WikiEntityExtractionService {
|
|||||||
if (alreadyProcessed) {
|
if (alreadyProcessed) {
|
||||||
clearChunkArtifacts(chunk.getId());
|
clearChunkArtifacts(chunk.getId());
|
||||||
}
|
}
|
||||||
persistChunk(kbId, chunk, result, resolved, index);
|
persistChunk(kbId, chunk, result, resolved, index, relationSchema);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[WikiEntity] Extraction failed for chunkId={} kbId={}: {}",
|
log.warn("[WikiEntity] Extraction failed for chunkId={} kbId={}: {}",
|
||||||
chunk.getId(), kbId, e.getMessage());
|
chunk.getId(), kbId, e.getMessage());
|
||||||
@ -180,27 +181,44 @@ public class WikiEntityExtractionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildSystemPrompt(List<String> types) {
|
private String buildSystemPrompt(List<String> types, List<WikiKbConfig.RelationSchemaEntry> relationSchema) {
|
||||||
return "You are a knowledge-graph entity extractor. From the given source text, "
|
StringBuilder sb = new StringBuilder()
|
||||||
+ "extract named entities and the factual relations between them.\n"
|
.append("You are a knowledge-graph entity extractor. From the given source text, ")
|
||||||
+ "Entity types to use: " + String.join(", ", types) + ".\n"
|
.append("extract named entities and the factual relations between them.\n")
|
||||||
+ "Rules:\n"
|
.append("Entity types to use: ").append(String.join(", ", types)).append(".\n")
|
||||||
+ "- Only extract entities explicitly named in the text; do not invent any.\n"
|
.append("Rules:\n")
|
||||||
+ "- Use the most complete surface form as the name; list shorter forms as aliases.\n"
|
.append("- Only extract entities explicitly named in the text; do not invent any.\n")
|
||||||
+ "- For each relation, subject and object must both appear in the entities list.\n"
|
.append("- Use the most complete surface form as the name; list shorter forms as aliases.\n")
|
||||||
+ "- Keep predicates short and snake_case (e.g. works_for, located_in, founded).\n"
|
.append("- For each relation, subject and object must both appear in the entities list.\n")
|
||||||
+ "- Provide a short verbatim evidence quote for each entity and relation.\n"
|
.append("- Keep predicates short and snake_case (e.g. works_for, located_in, founded).\n")
|
||||||
+ "- If nothing relevant is present, return empty lists.";
|
.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 ------------------------------------------------------
|
// ---- persistence ------------------------------------------------------
|
||||||
|
|
||||||
private void persistChunk(Long kbId, WikiChunkEntity chunk, EntityExtractionResult result,
|
private void persistChunk(Long kbId, WikiChunkEntity chunk, EntityExtractionResult result,
|
||||||
Map<String, Long> resolved, EntityIndex index) {
|
Map<String, Long> resolved, EntityIndex index,
|
||||||
|
List<WikiKbConfig.RelationSchemaEntry> relationSchema) {
|
||||||
Long pageId = firstCitingPage(chunk.getId());
|
Long pageId = firstCitingPage(chunk.getId());
|
||||||
|
|
||||||
// Resolve each entity to a canonical id, persist its mention for this chunk.
|
// Resolve each entity to a canonical id, persist its mention for this chunk.
|
||||||
Map<String, Long> localByName = new HashMap<>();
|
Map<String, Long> localByName = new HashMap<>();
|
||||||
|
Map<String, String> localTypeByName = new HashMap<>();
|
||||||
if (result.getEntities() != null) {
|
if (result.getEntities() != null) {
|
||||||
for (EntityExtractionResult.ExtractedEntity e : result.getEntities()) {
|
for (EntityExtractionResult.ExtractedEntity e : result.getEntities()) {
|
||||||
if (e == null || e.getName() == null || e.getName().isBlank()) {
|
if (e == null || e.getName() == null || e.getName().isBlank()) {
|
||||||
@ -212,10 +230,12 @@ public class WikiEntityExtractionService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
localByName.put(normalize(e.getName()), entityId);
|
localByName.put(normalize(e.getName()), entityId);
|
||||||
|
localTypeByName.put(normalize(e.getName()), type);
|
||||||
if (e.getAliases() != null) {
|
if (e.getAliases() != null) {
|
||||||
for (String alias : e.getAliases()) {
|
for (String alias : e.getAliases()) {
|
||||||
if (alias != null && !alias.isBlank()) {
|
if (alias != null && !alias.isBlank()) {
|
||||||
localByName.put(normalize(alias), entityId);
|
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) {
|
if (result.getRelations() != null) {
|
||||||
for (EntityExtractionResult.ExtractedRelation r : result.getRelations()) {
|
for (EntityExtractionResult.ExtractedRelation r : result.getRelations()) {
|
||||||
if (r == null || r.getSubject() == null || r.getObject() == null
|
if (r == null || r.getSubject() == null || r.getObject() == null
|
||||||
@ -236,12 +259,49 @@ public class WikiEntityExtractionService {
|
|||||||
if (subjectId == null || objectId == null || subjectId.equals(objectId)) {
|
if (subjectId == null || objectId == null || subjectId.equals(objectId)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
upsertRelation(kbId, subjectId, objectId, normalizePredicate(r.getPredicate()),
|
String predicate = normalizePredicate(r.getPredicate());
|
||||||
r.getEvidence(), chunk.getId());
|
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<WikiKbConfig.RelationSchemaEntry> 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
|
* Resolve an extracted entity to a canonical node id: run cache → exact
|
||||||
* key match in DB → embedding near-match → create new.
|
* key match in DB → embedding near-match → create new.
|
||||||
@ -457,6 +517,16 @@ public class WikiEntityExtractionService {
|
|||||||
return DEFAULT_ENTITY_TYPES;
|
return DEFAULT_ENTITY_TYPES;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<WikiKbConfig.RelationSchemaEntry> 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) {
|
private String normalize(String s) {
|
||||||
return s == null ? "" : s.trim().toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
|
return s == null ? "" : s.trim().toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -172,6 +172,46 @@ class WikiEntityExtractionServiceTest {
|
|||||||
verify(mentionMapper, times(2)).insert(any(WikiEntityMentionEntity.class));
|
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
|
@Test
|
||||||
@DisplayName("extractForRaw: skips chunks that already have mentions")
|
@DisplayName("extractForRaw: skips chunks that already have mentions")
|
||||||
void extractForRaw_skipsProcessedChunks() {
|
void extractForRaw_skipsProcessedChunks() {
|
||||||
|
|||||||
@ -2739,6 +2739,12 @@ export default {
|
|||||||
entityTypesLabel: 'Entity types to extract',
|
entityTypesLabel: 'Entity types to extract',
|
||||||
entityTypesPlaceholder: 'Select or type an entity type, Enter to add',
|
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).',
|
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',
|
modelStrategy: 'Model Strategy',
|
||||||
globalDefault: 'Global default',
|
globalDefault: 'Global default',
|
||||||
selectModel: 'Select a model…',
|
selectModel: 'Select a model…',
|
||||||
|
|||||||
@ -2751,6 +2751,12 @@ export default {
|
|||||||
entityTypesLabel: '抽取的实体类型',
|
entityTypesLabel: '抽取的实体类型',
|
||||||
entityTypesPlaceholder: '选择或输入实体类型,回车添加',
|
entityTypesPlaceholder: '选择或输入实体类型,回车添加',
|
||||||
entityTypesHint: '限定要抽取的实体类型;留空则使用内置默认类型(人物、组织、地点、事件、产品、概念)。',
|
entityTypesHint: '限定要抽取的实体类型;留空则使用内置默认类型(人物、组织、地点、事件、产品、概念)。',
|
||||||
|
relationSchemaLabel: '关系模式',
|
||||||
|
relationSchemaHint: '限定只抽取以下几条确定的关系三元组,其余关系与不参与这些关系的实体一律忽略;留空则保持开放抽取(模型自行判断关系)。',
|
||||||
|
relationSchemaSubject: '主体类型',
|
||||||
|
relationSchemaPredicate: '关系(如 works_for)',
|
||||||
|
relationSchemaObject: '客体类型',
|
||||||
|
relationSchemaAdd: '+ 添加一条关系',
|
||||||
modelStrategy: '模型策略',
|
modelStrategy: '模型策略',
|
||||||
globalDefault: '跟随全局默认',
|
globalDefault: '跟随全局默认',
|
||||||
selectModel: '选择可用模型…',
|
selectModel: '选择可用模型…',
|
||||||
|
|||||||
@ -90,6 +90,54 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
<div class="entity-types__hint">{{ t('wiki.configPanel.entityTypesHint') }}</div>
|
<div class="entity-types__hint">{{ t('wiki.configPanel.entityTypesHint') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Relation schema: optional closed whitelist of (subjectType, predicate, objectType)
|
||||||
|
triples. Empty = open-vocabulary relations (legacy behavior). -->
|
||||||
|
<div v-if="entityExtractionEnabled" class="relation-schema">
|
||||||
|
<div class="relation-schema__label">{{ t('wiki.configPanel.relationSchemaLabel') }}</div>
|
||||||
|
<div class="relation-schema__hint">{{ t('wiki.configPanel.relationSchemaHint') }}</div>
|
||||||
|
|
||||||
|
<div v-for="(row, idx) in relationSchema" :key="idx" class="relation-schema__row">
|
||||||
|
<el-select
|
||||||
|
v-model="row.subjectType"
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
size="small"
|
||||||
|
class="relation-schema__type"
|
||||||
|
:placeholder="t('wiki.configPanel.relationSchemaSubject')"
|
||||||
|
>
|
||||||
|
<el-option v-for="opt in relationSchemaTypeOptions" :key="opt" :label="formatEntityType(opt)" :value="opt" />
|
||||||
|
</el-select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model.trim="row.predicate"
|
||||||
|
class="relation-schema__predicate"
|
||||||
|
:placeholder="t('wiki.configPanel.relationSchemaPredicate')"
|
||||||
|
/>
|
||||||
|
<el-select
|
||||||
|
v-model="row.objectType"
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
size="small"
|
||||||
|
class="relation-schema__type"
|
||||||
|
:placeholder="t('wiki.configPanel.relationSchemaObject')"
|
||||||
|
>
|
||||||
|
<el-option v-for="opt in relationSchemaTypeOptions" :key="opt" :label="formatEntityType(opt)" :value="opt" />
|
||||||
|
</el-select>
|
||||||
|
<button
|
||||||
|
class="relation-schema__remove"
|
||||||
|
@click="removeRelationSchemaRow(idx)"
|
||||||
|
:title="t('common.delete')"
|
||||||
|
:aria-label="t('common.delete')"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-save btn-save--ghost relation-schema__add" @click="addRelationSchemaRow">
|
||||||
|
{{ t('wiki.configPanel.relationSchemaAdd') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ② Model strategy -->
|
<!-- ② Model strategy -->
|
||||||
@ -297,6 +345,24 @@ const entityTypes = ref<string[]>([])
|
|||||||
const savingEntityExtraction = ref(false)
|
const savingEntityExtraction = ref(false)
|
||||||
const extracting = 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<RelationSchemaRow[]>([])
|
||||||
|
|
||||||
|
// 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() {
|
async function saveEntityExtraction() {
|
||||||
if (!store.currentKB) return
|
if (!store.currentKB) return
|
||||||
savingEntityExtraction.value = true
|
savingEntityExtraction.value = true
|
||||||
@ -312,6 +378,18 @@ async function saveEntityExtraction() {
|
|||||||
? [...new Set(entityTypes.value.map(s => s.trim().toLowerCase()).filter(Boolean))]
|
? [...new Set(entityTypes.value.map(s => s.trim().toLowerCase()).filter(Boolean))]
|
||||||
: []
|
: []
|
||||||
existingConfig.entityTypes = cleanedTypes.length > 0 ? cleanedTypes : undefined
|
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))
|
await wikiApi.updateConfig(store.currentKB.id, JSON.stringify(existingConfig, null, 2))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[WikiConfig] Failed to save entity extraction toggle', e)
|
console.error('[WikiConfig] Failed to save entity extraction toggle', e)
|
||||||
@ -352,6 +430,7 @@ function loadStepModels() {
|
|||||||
ingestMode.value = 'eager'
|
ingestMode.value = 'eager'
|
||||||
entityExtractionEnabled.value = false
|
entityExtractionEnabled.value = false
|
||||||
entityTypes.value = []
|
entityTypes.value = []
|
||||||
|
relationSchema.value = []
|
||||||
if (!store.currentKB) return
|
if (!store.currentKB) return
|
||||||
try {
|
try {
|
||||||
const cfg = store.currentKB.configContent ? JSON.parse(store.currentKB.configContent) : null
|
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?.ingestMode === 'lazy') ingestMode.value = 'lazy'
|
||||||
if (cfg?.entityExtractionEnabled) entityExtractionEnabled.value = true
|
if (cfg?.entityExtractionEnabled) entityExtractionEnabled.value = true
|
||||||
if (Array.isArray(cfg?.entityTypes)) entityTypes.value = cfg.entityTypes.map(String)
|
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 */ }
|
} catch { /* not JSON */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -596,6 +682,38 @@ loadProviderNames().then(() => {
|
|||||||
.entity-types__select { width: 100%; }
|
.entity-types__select { width: 100%; }
|
||||||
.entity-types__hint { font-size: 11px; color: var(--mc-text-tertiary); line-height: 1.4; }
|
.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 radio group */
|
||||||
.ingest-mode-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
.ingest-mode-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
.ingest-mode-option {
|
.ingest-mode-option {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user