From eeadc33d6f91a2228f6ee8aabeb3e52d88459db1 Mon Sep 17 00:00:00 2001 From: shenlang Date: Fri, 27 Mar 2026 22:06:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=9F=A5=E8=AF=86=E5=9B=BE=E8=B0=B1):=20?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E5=AE=9E=E4=BD=93=E5=92=8C=E5=85=B3=E7=B3=BB?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=B9=B6=E6=B7=BB=E5=8A=A0=E6=96=B0=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为实体和关系添加时间戳、标签和优先级字段 - 实现实体和关系的更新方法并添加验证 - 增强搜索功能,支持按优先级、相关性和日期排序 - 添加按标签搜索、获取最近更新实体和相关实体功能 - 更新工具接口以支持新功能 --- src/memory/index.ts | 492 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 458 insertions(+), 34 deletions(-) diff --git a/src/memory/index.ts b/src/memory/index.ts index b560bf1e53..23ce3d6b2d 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -51,12 +51,18 @@ export interface Entity { name: string; entityType: string; observations: string[]; + tags: string[]; + createdAt: string; + updatedAt: string; + priority: number; // 1-5, 5 is highest } export interface Relation { from: string; to: string; relationType: string; + createdAt: string; + updatedAt: string; } export interface KnowledgeGraph { @@ -78,14 +84,20 @@ export class KnowledgeGraphManager { graph.entities.push({ name: item.name, entityType: item.entityType, - observations: item.observations + observations: item.observations || [], + tags: item.tags || [], + createdAt: item.createdAt || new Date().toISOString(), + updatedAt: item.updatedAt || new Date().toISOString(), + priority: item.priority || 3 }); } if (item.type === "relation") { graph.relations.push({ from: item.from, to: item.to, - relationType: item.relationType + relationType: item.relationType, + createdAt: item.createdAt || new Date().toISOString(), + updatedAt: item.updatedAt || new Date().toISOString() }); } return graph; @@ -104,13 +116,19 @@ export class KnowledgeGraphManager { type: "entity", name: e.name, entityType: e.entityType, - observations: e.observations + observations: e.observations, + tags: e.tags, + createdAt: e.createdAt, + updatedAt: e.updatedAt, + priority: e.priority })), ...graph.relations.map(r => JSON.stringify({ type: "relation", from: r.from, to: r.to, - relationType: r.relationType + relationType: r.relationType, + createdAt: r.createdAt, + updatedAt: r.updatedAt })), ]; await fs.writeFile(this.memoryFilePath, lines.join("\n")); @@ -119,9 +137,17 @@ export class KnowledgeGraphManager { async createEntities(entities: Entity[]): Promise { const graph = await this.loadGraph(); const newEntities = entities.filter(e => !graph.entities.some(existingEntity => existingEntity.name === e.name)); - graph.entities.push(...newEntities); + const now = new Date().toISOString(); + const entitiesWithDefaults = newEntities.map(e => ({ + ...e, + tags: e.tags || [], + createdAt: e.createdAt || now, + updatedAt: e.updatedAt || now, + priority: e.priority || 3 + })); + graph.entities.push(...entitiesWithDefaults); await this.saveGraph(graph); - return newEntities; + return entitiesWithDefaults; } async createRelations(relations: Relation[]): Promise { @@ -131,9 +157,15 @@ export class KnowledgeGraphManager { existingRelation.to === r.to && existingRelation.relationType === r.relationType )); - graph.relations.push(...newRelations); + const now = new Date().toISOString(); + const relationsWithDefaults = newRelations.map(r => ({ + ...r, + createdAt: r.createdAt || now, + updatedAt: r.updatedAt || now + })); + graph.relations.push(...relationsWithDefaults); await this.saveGraph(graph); - return newRelations; + return relationsWithDefaults; } async addObservations(observations: { entityName: string; contents: string[] }[]): Promise<{ entityName: string; addedObservations: string[] }[]> { @@ -145,12 +177,139 @@ export class KnowledgeGraphManager { } const newObservations = o.contents.filter(content => !entity.observations.includes(content)); entity.observations.push(...newObservations); + entity.updatedAt = new Date().toISOString(); return { entityName: o.entityName, addedObservations: newObservations }; }); await this.saveGraph(graph); return results; } + async updateEntity(entityName: string, updates: Partial): Promise { + const graph = await this.loadGraph(); + const entity = graph.entities.find(e => e.name === entityName); + if (!entity) { + return null; + } + + // 验证并过滤有效的更新字段 + const validUpdates: Partial = {}; + + // 验证 entityType + if (updates.entityType !== undefined) { + if (typeof updates.entityType === 'string' && updates.entityType.trim() !== '') { + validUpdates.entityType = updates.entityType.trim(); + } else { + throw new Error('entityType must be a non-empty string'); + } + } + + // 验证 observations + if (updates.observations !== undefined) { + if (Array.isArray(updates.observations) && updates.observations.every(o => typeof o === 'string')) { + validUpdates.observations = updates.observations; + } else { + throw new Error('observations must be an array of strings'); + } + } + + // 验证 tags + if (updates.tags !== undefined) { + if (Array.isArray(updates.tags) && updates.tags.every(t => typeof t === 'string')) { + validUpdates.tags = updates.tags; + } else { + throw new Error('tags must be an array of strings'); + } + } + + // 验证 priority + if (updates.priority !== undefined) { + if (typeof updates.priority === 'number' && updates.priority >= 1 && updates.priority <= 5) { + validUpdates.priority = updates.priority; + } else { + throw new Error('priority must be a number between 1 and 5'); + } + } + + // 应用验证后的更新 + Object.assign(entity, validUpdates); + entity.updatedAt = new Date().toISOString(); + await this.saveGraph(graph); + return entity; + } + + async updateRelation(from: string, to: string, relationType: string, updates: Partial): Promise { + const graph = await this.loadGraph(); + const relation = graph.relations.find(r => + r.from === from && r.to === to && r.relationType === relationType + ); + if (!relation) { + return null; + } + + // 验证并过滤有效的更新字段 + const validUpdates: Partial = {}; + + // 验证 from + if (updates.from !== undefined) { + if (typeof updates.from === 'string' && updates.from.trim() !== '') { + validUpdates.from = updates.from.trim(); + } else { + throw new Error('from must be a non-empty string'); + } + } + + // 验证 to + if (updates.to !== undefined) { + if (typeof updates.to === 'string' && updates.to.trim() !== '') { + validUpdates.to = updates.to.trim(); + } else { + throw new Error('to must be a non-empty string'); + } + } + + // 验证 relationType + if (updates.relationType !== undefined) { + if (typeof updates.relationType === 'string' && updates.relationType.trim() !== '') { + validUpdates.relationType = updates.relationType.trim(); + } else { + throw new Error('relationType must be a non-empty string'); + } + } + + // 应用验证后的更新 + Object.assign(relation, validUpdates); + relation.updatedAt = new Date().toISOString(); + await this.saveGraph(graph); + return relation; + } + + async getRelatedEntities(entityName: string): Promise { + const graph = await this.loadGraph(); + const entity = graph.entities.find(e => e.name === entityName); + if (!entity) { + return { entities: [], relations: [] }; + } + const relatedEntityNames = new Set(); + const relatedRelations = graph.relations.filter(r => { + if (r.from === entityName) { + relatedEntityNames.add(r.to); + return true; + } + if (r.to === entityName) { + relatedEntityNames.add(r.from); + return true; + } + return false; + }); + const relatedEntities = graph.entities.filter(e => + e.name === entityName || relatedEntityNames.has(e.name) + ); + return { + entities: relatedEntities, + relations: relatedRelations + }; + } + async deleteEntities(entityNames: string[]): Promise { const graph = await this.loadGraph(); graph.entities = graph.entities.filter(e => !entityNames.includes(e.name)); @@ -163,7 +322,12 @@ export class KnowledgeGraphManager { deletions.forEach(d => { const entity = graph.entities.find(e => e.name === d.entityName); if (entity) { + const originalLength = entity.observations.length; entity.observations = entity.observations.filter(o => !d.observations.includes(o)); + // 只有当有观察记录被删除时才更新时间戳 + if (entity.observations.length < originalLength) { + entity.updatedAt = new Date().toISOString(); + } } }); await this.saveGraph(graph); @@ -183,34 +347,131 @@ export class KnowledgeGraphManager { return this.loadGraph(); } - // Very basic search function - async searchNodes(query: string): Promise { + // Enhanced search function with priority and relevance sorting + async searchNodes(query: string, options?: { sortBy?: 'priority' | 'relevance' | 'date'; limit?: number }): Promise { const graph = await this.loadGraph(); + const queryLower = query.toLowerCase(); - // Filter entities - const filteredEntities = graph.entities.filter(e => - e.name.toLowerCase().includes(query.toLowerCase()) || - e.entityType.toLowerCase().includes(query.toLowerCase()) || - e.observations.some(o => o.toLowerCase().includes(query.toLowerCase())) - ); + // Filter and score entities based on relevance + const scoredEntities = graph.entities + .map(e => { + let score = 0; + + // Name matching (highest priority) + if (e.name.toLowerCase() === queryLower) score += 100; + else if (e.name.toLowerCase().includes(queryLower)) score += 80; + + // Entity type matching + if (e.entityType.toLowerCase() === queryLower) score += 70; + else if (e.entityType.toLowerCase().includes(queryLower)) score += 50; + + // Tag matching + if (e.tags.some(tag => tag.toLowerCase() === queryLower)) score += 60; + else if (e.tags.some(tag => tag.toLowerCase().includes(queryLower))) score += 40; + + // Observation matching + const matchingObservations = e.observations.filter(o => + o.toLowerCase().includes(queryLower) + ); + score += matchingObservations.length * 20; + + // Priority boost + score += e.priority * 5; + + return { entity: e, score }; + }) + .filter(({ score }) => score > 0) + .sort((a, b) => { + if (options?.sortBy === 'priority') { + return b.entity.priority - a.entity.priority; + } else if (options?.sortBy === 'date') { + return new Date(b.entity.updatedAt).getTime() - new Date(a.entity.updatedAt).getTime(); + } else { // relevance + return b.score - a.score; + } + }); + + // Apply limit if specified + const limitedEntities = options?.limit + ? scoredEntities.slice(0, options.limit).map(({ entity }) => entity) + : scoredEntities.map(({ entity }) => entity); // Create a Set of filtered entity names for quick lookup - const filteredEntityNames = new Set(filteredEntities.map(e => e.name)); + const filteredEntityNames = new Set(limitedEntities.map(e => e.name)); - // Include relations where at least one endpoint matches the search results. - // This lets callers discover connections to nodes outside the result set. + // Include relations where at least one endpoint matches the search results const filteredRelations = graph.relations.filter(r => filteredEntityNames.has(r.from) || filteredEntityNames.has(r.to) ); const filteredGraph: KnowledgeGraph = { - entities: filteredEntities, + entities: limitedEntities, relations: filteredRelations, }; return filteredGraph; } + async searchByTag(tag: string, options?: { sortBy?: 'priority' | 'date'; limit?: number }): Promise { + const graph = await this.loadGraph(); + const tagLower = tag.toLowerCase(); + + // Filter entities by tag + let filteredEntities = graph.entities.filter(e => + e.tags.some(t => t.toLowerCase() === tagLower) + ); + + // Apply sorting + if (options?.sortBy === 'priority') { + filteredEntities.sort((a, b) => b.priority - a.priority); + } else if (options?.sortBy === 'date') { + filteredEntities.sort((a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + } + + // Apply limit if specified + if (options?.limit) { + filteredEntities = filteredEntities.slice(0, options.limit); + } + + // Create a Set of filtered entity names for quick lookup + const filteredEntityNames = new Set(filteredEntities.map(e => e.name)); + + // Include relations where at least one endpoint matches the search results + const filteredRelations = graph.relations.filter(r => + filteredEntityNames.has(r.from) || filteredEntityNames.has(r.to) + ); + + return { + entities: filteredEntities, + relations: filteredRelations + }; + } + + async getRecentEntities(days: number = 7, limit: number = 10): Promise { + const graph = await this.loadGraph(); + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - days); + + const recentEntities = graph.entities + .filter(e => new Date(e.updatedAt) >= cutoffDate) + .sort((a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ) + .slice(0, limit); + + const recentEntityNames = new Set(recentEntities.map(e => e.name)); + const recentRelations = graph.relations.filter(r => + recentEntityNames.has(r.from) || recentEntityNames.has(r.to) + ); + + return { + entities: recentEntities, + relations: recentRelations + }; + } + async openNodes(names: string[]): Promise { const graph = await this.loadGraph(); @@ -243,13 +504,61 @@ let knowledgeGraphManager: KnowledgeGraphManager; const EntitySchema = z.object({ name: z.string().describe("The name of the entity"), entityType: z.string().describe("The type of the entity"), - observations: z.array(z.string()).describe("An array of observation contents associated with the entity") + observations: z.array(z.string()).describe("An array of observation contents associated with the entity"), + tags: z.array(z.string()).describe("An array of tags associated with the entity"), + createdAt: z.string().optional().describe("The creation time of the entity"), + updatedAt: z.string().optional().describe("The last update time of the entity"), + priority: z.number().min(1).max(5).optional().describe("The priority of the entity (1-5, 5 is highest)") }); const RelationSchema = z.object({ from: z.string().describe("The name of the entity where the relation starts"), to: z.string().describe("The name of the entity where the relation ends"), - relationType: z.string().describe("The type of the relation") + relationType: z.string().describe("The type of the relation"), + createdAt: z.string().optional().describe("The creation time of the relation"), + updatedAt: z.string().optional().describe("The last update time of the relation") +}); + +const EntityUpdateSchema = z.object({ + entityName: z.string().describe("The name of the entity to update"), + updates: z.object({ + entityType: z.string().optional().describe("The updated type of the entity"), + observations: z.array(z.string()).optional().describe("The updated observations of the entity"), + tags: z.array(z.string()).optional().describe("The updated tags of the entity"), + priority: z.number().min(1).max(5).optional().describe("The updated priority of the entity") + }).describe("The updates to apply to the entity") +}); + +const RelationUpdateSchema = z.object({ + from: z.string().describe("The name of the entity where the relation starts"), + to: z.string().describe("The name of the entity where the relation ends"), + relationType: z.string().describe("The type of the relation"), + updates: z.object({ + from: z.string().optional().describe("The updated name of the entity where the relation starts"), + to: z.string().optional().describe("The updated name of the entity where the relation ends"), + relationType: z.string().optional().describe("The updated type of the relation") + }).describe("The updates to apply to the relation") +}); + +const TagSearchSchema = z.object({ + tag: z.string().describe("The tag to search for"), + sortBy: z.enum(["priority", "date"]).optional().describe("The sorting criteria"), + limit: z.number().optional().describe("The maximum number of results to return") +}); + +const RecentEntitiesSchema = z.object({ + days: z.number().optional().describe("The number of days to look back"), + limit: z.number().optional().describe("The maximum number of results to return") +}); + +const RelatedEntitiesSchema = z.object({ + entityName: z.string().describe("The name of the entity to find related entities for") +}); + +const SearchNodesSchema = z.object({ + query: z.string().describe("The search query to match against entity names, types, and observation content"), + sortBy: z.enum(["priority", "relevance", "date"]).optional().describe("The sorting criteria"), + limit: z.number().optional().describe("The maximum number of results to return") }); // The server instance and tools exposed to Claude @@ -265,14 +574,20 @@ server.registerTool( title: "Create Entities", description: "Create multiple new entities in the knowledge graph", inputSchema: { - entities: z.array(EntitySchema) + entities: z.array(z.object({ + name: z.string().describe("The name of the entity"), + entityType: z.string().describe("The type of the entity"), + observations: z.array(z.string()).describe("An array of observation contents associated with the entity"), + tags: z.array(z.string()).optional().describe("An array of tags associated with the entity"), + priority: z.number().min(1).max(5).optional().describe("The priority of the entity (1-5, 5 is highest)") + })) }, outputSchema: { entities: z.array(EntitySchema) } }, async ({ entities }) => { - const result = await knowledgeGraphManager.createEntities(entities); + const result = await knowledgeGraphManager.createEntities(entities as Entity[]); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], structuredContent: { entities: result } @@ -287,14 +602,18 @@ server.registerTool( title: "Create Relations", description: "Create multiple new relations between entities in the knowledge graph. Relations should be in active voice", inputSchema: { - relations: z.array(RelationSchema) + relations: z.array(z.object({ + from: z.string().describe("The name of the entity where the relation starts"), + to: z.string().describe("The name of the entity where the relation ends"), + relationType: z.string().describe("The type of the relation") + })) }, outputSchema: { relations: z.array(RelationSchema) } }, async ({ relations }) => { - const result = await knowledgeGraphManager.createRelations(relations); + const result = await knowledgeGraphManager.createRelations(relations as Relation[]); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], structuredContent: { relations: result } @@ -386,7 +705,11 @@ server.registerTool( title: "Delete Relations", description: "Delete multiple relations from the knowledge graph", inputSchema: { - relations: z.array(RelationSchema).describe("An array of relations to delete") + relations: z.array(z.object({ + from: z.string().describe("The name of the entity where the relation starts"), + to: z.string().describe("The name of the entity where the relation ends"), + relationType: z.string().describe("The type of the relation") + })).describe("An array of relations to delete") }, outputSchema: { success: z.boolean(), @@ -394,7 +717,7 @@ server.registerTool( } }, async ({ relations }) => { - await knowledgeGraphManager.deleteRelations(relations); + await knowledgeGraphManager.deleteRelations(relations as Relation[]); return { content: [{ type: "text" as const, text: "Relations deleted successfully" }], structuredContent: { success: true, message: "Relations deleted successfully" } @@ -428,17 +751,118 @@ server.registerTool( "search_nodes", { title: "Search Nodes", - description: "Search for nodes in the knowledge graph based on a query", - inputSchema: { - query: z.string().describe("The search query to match against entity names, types, and observation content") - }, + description: "Search for nodes in the knowledge graph based on a query with sorting and limit options", + inputSchema: SearchNodesSchema, + outputSchema: { + entities: z.array(EntitySchema), + relations: z.array(RelationSchema) + } + }, + async ({ query, sortBy, limit }) => { + const graph = await knowledgeGraphManager.searchNodes(query, { sortBy, limit }); + return { + content: [{ type: "text" as const, text: JSON.stringify(graph, null, 2) }], + structuredContent: { ...graph } + }; + } +); + +// Register update_entity tool +server.registerTool( + "update_entity", + { + title: "Update Entity", + description: "Update an existing entity in the knowledge graph", + inputSchema: EntityUpdateSchema, + outputSchema: { + entity: z.union([EntitySchema, z.null()]) + } + }, + async ({ entityName, updates }) => { + const entity = await knowledgeGraphManager.updateEntity(entityName, updates); + return { + content: [{ type: "text" as const, text: entity ? JSON.stringify(entity, null, 2) : "Entity not found" }], + structuredContent: { entity } + }; + } +); + +// Register update_relation tool +server.registerTool( + "update_relation", + { + title: "Update Relation", + description: "Update an existing relation in the knowledge graph", + inputSchema: RelationUpdateSchema, + outputSchema: { + relation: z.union([RelationSchema, z.null()]) + } + }, + async ({ from, to, relationType, updates }) => { + const relation = await knowledgeGraphManager.updateRelation(from, to, relationType, updates); + return { + content: [{ type: "text" as const, text: relation ? JSON.stringify(relation, null, 2) : "Relation not found" }], + structuredContent: { relation } + }; + } +); + +// Register search_by_tag tool +server.registerTool( + "search_by_tag", + { + title: "Search by Tag", + description: "Search for entities in the knowledge graph based on a tag", + inputSchema: TagSearchSchema, + outputSchema: { + entities: z.array(EntitySchema), + relations: z.array(RelationSchema) + } + }, + async ({ tag, sortBy, limit }) => { + const graph = await knowledgeGraphManager.searchByTag(tag, { sortBy, limit }); + return { + content: [{ type: "text" as const, text: JSON.stringify(graph, null, 2) }], + structuredContent: { ...graph } + }; + } +); + +// Register get_recent_entities tool +server.registerTool( + "get_recent_entities", + { + title: "Get Recent Entities", + description: "Get entities that have been updated recently", + inputSchema: RecentEntitiesSchema, + outputSchema: { + entities: z.array(EntitySchema), + relations: z.array(RelationSchema) + } + }, + async ({ days, limit }) => { + const graph = await knowledgeGraphManager.getRecentEntities(days, limit); + return { + content: [{ type: "text" as const, text: JSON.stringify(graph, null, 2) }], + structuredContent: { ...graph } + }; + } +); + +// Register get_related_entities tool +server.registerTool( + "get_related_entities", + { + title: "Get Related Entities", + description: "Get entities related to a specific entity", + inputSchema: RelatedEntitiesSchema, outputSchema: { entities: z.array(EntitySchema), relations: z.array(RelationSchema) } }, - async ({ query }) => { - const graph = await knowledgeGraphManager.searchNodes(query); + async ({ entityName }) => { + const graph = await knowledgeGraphManager.getRelatedEntities(entityName); return { content: [{ type: "text" as const, text: JSON.stringify(graph, null, 2) }], structuredContent: { ...graph }