fix(memory): validate knowledge graph entries when loading from disk (#4717)
loadGraph() trusted the persisted memory file and pushed entities and relations without validating their fields. A corrupted or legacy entry (e.g. an entity missing entityType, or an observation that is not a string) would reach searchNodes and crash with "Cannot read properties of undefined (reading 'toLowerCase')". Validate each line against the existing EntitySchema/RelationSchema and skip malformed entries with a warning, so the in-memory graph only ever contains well-formed data. Malformed JSON lines are skipped as well. Fixes #2044
This commit is contained in:
parent
abbbddb2e3
commit
c3d8e43d1f
2 changed files with 86 additions and 16 deletions
|
|
@ -617,4 +617,51 @@ describe('KnowledgeGraphManager', () => {
|
||||||
expect(result.relations[0]).not.toHaveProperty('type');
|
expect(result.relations[0]).not.toHaveProperty('type');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('loadGraph validation', () => {
|
||||||
|
it('skips corrupt entities instead of crashing search', async () => {
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ type: 'entity', name: 'Alice', entityType: 'person', observations: ['works at Acme Corp'] }),
|
||||||
|
JSON.stringify({ type: 'entity', name: 'Broken', observations: ['missing entityType'] }),
|
||||||
|
JSON.stringify({ type: 'entity', name: 'BadObs', entityType: 'person', observations: ['ok', null] }),
|
||||||
|
];
|
||||||
|
await fs.writeFile(testFilePath, lines.join('\n') + '\n');
|
||||||
|
|
||||||
|
const graph = await manager.readGraph();
|
||||||
|
expect(graph.entities).toHaveLength(1);
|
||||||
|
expect(graph.entities[0].name).toBe('Alice');
|
||||||
|
|
||||||
|
// searchNodes must not throw even though the file contains corrupt entries
|
||||||
|
const result = await manager.searchNodes('Acme');
|
||||||
|
expect(result.entities).toHaveLength(1);
|
||||||
|
expect(result.entities[0].name).toBe('Alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips corrupt relations', async () => {
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ type: 'entity', name: 'Alice', entityType: 'person', observations: [] }),
|
||||||
|
JSON.stringify({ type: 'relation', from: 'Alice', to: 'Bob' }), // missing relationType
|
||||||
|
JSON.stringify({ type: 'relation', from: 'Alice', to: 'Bob', relationType: 'knows' }),
|
||||||
|
];
|
||||||
|
await fs.writeFile(testFilePath, lines.join('\n') + '\n');
|
||||||
|
|
||||||
|
const graph = await manager.readGraph();
|
||||||
|
expect(graph.entities).toHaveLength(1);
|
||||||
|
expect(graph.relations).toHaveLength(1);
|
||||||
|
expect(graph.relations[0].relationType).toBe('knows');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips malformed JSON lines', async () => {
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ type: 'entity', name: 'Alice', entityType: 'person', observations: [] }),
|
||||||
|
'{this is not valid json',
|
||||||
|
JSON.stringify({ type: 'entity', name: 'Bob', entityType: 'person', observations: [] }),
|
||||||
|
];
|
||||||
|
await fs.writeFile(testFilePath, lines.join('\n') + '\n');
|
||||||
|
|
||||||
|
const graph = await manager.readGraph();
|
||||||
|
expect(graph.entities).toHaveLength(2);
|
||||||
|
expect(graph.entities.map(e => e.name)).toEqual(['Alice', 'Bob']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -90,24 +90,47 @@ export class KnowledgeGraphManager {
|
||||||
try {
|
try {
|
||||||
const data = await fs.readFile(this.memoryFilePath, "utf-8");
|
const data = await fs.readFile(this.memoryFilePath, "utf-8");
|
||||||
const lines = data.split("\n").filter(line => line.trim() !== "");
|
const lines = data.split("\n").filter(line => line.trim() !== "");
|
||||||
return lines.reduce((graph: KnowledgeGraph, line) => {
|
const graph: KnowledgeGraph = { entities: [], relations: [] };
|
||||||
const item = JSON.parse(line);
|
|
||||||
if (item.type === "entity") {
|
for (const line of lines) {
|
||||||
graph.entities.push({
|
let item: unknown;
|
||||||
name: item.name,
|
try {
|
||||||
entityType: item.entityType,
|
item = JSON.parse(line);
|
||||||
observations: item.observations
|
} catch {
|
||||||
});
|
console.error("Skipping malformed line in memory file");
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if (item.type === "relation") {
|
|
||||||
graph.relations.push({
|
if (typeof item !== "object" || item === null) {
|
||||||
from: item.from,
|
console.error("Skipping non-object line in memory file");
|
||||||
to: item.to,
|
continue;
|
||||||
relationType: item.relationType
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return graph;
|
|
||||||
}, { entities: [], relations: [] });
|
const record = item as Record<string, unknown>;
|
||||||
|
if (record.type === "entity") {
|
||||||
|
const parsed = EntitySchema.safeParse(item);
|
||||||
|
if (parsed.success) {
|
||||||
|
graph.entities.push(parsed.data);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"Skipping invalid entity in memory file:",
|
||||||
|
parsed.error.issues.map(issue => `${issue.path.join(".")}: ${issue.message}`).join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (record.type === "relation") {
|
||||||
|
const parsed = RelationSchema.safeParse(item);
|
||||||
|
if (parsed.success) {
|
||||||
|
graph.relations.push(parsed.data);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"Skipping invalid relation in memory file:",
|
||||||
|
parsed.error.issues.map(issue => `${issue.path.join(".")}: ${issue.message}`).join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return graph;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && 'code' in error && (error as any).code === "ENOENT") {
|
if (error instanceof Error && 'code' in error && (error as any).code === "ENOENT") {
|
||||||
return { entities: [], relations: [] };
|
return { entities: [], relations: [] };
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue