openclaw/extensions/memory-ruvector/graph/relationships.ts
File a801c7e721 feat(memory-ruvector): add ruvLLM adaptive learning features
Implements ruvLLM integration with multi-temporal learning:

P0 - Foundation:
- Extended config schema for ruvllm options
- TrajectoryRecorder for search pattern recording
- ContextInjector for agent prompt enrichment
- SONA engine integration with trajectory support

P1 - Learning Core:
- PatternStore with K-means++ clustering
- Search re-ranking using learned patterns
- GraphExpander for automatic edge discovery
- ruvector_recall tool (pattern-aware recall)

P2 - Adaptive Loops:
- BackgroundLoop (30s interval pattern clustering)
- InstantLoop (real-time feedback processing)
- RelationshipInferrer (entity extraction)
- ruvector_learn tool (manual knowledge injection)

P3 - Advanced Features:
- EWCConsolidator (catastrophic forgetting prevention)
- ConsolidationLoop (deep pattern analysis)
- GraphAttention (multi-head context aggregation)
- Pattern export/import CLI commands

Tests: 275 passing (229 + 46 new)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 08:14:01 +01:00

729 lines
20 KiB
TypeScript

/**
* Automatic Relationship Inference for Knowledge Graph
*
* Provides entity extraction, relationship detection, and automatic linking
* based on vector similarity. Integrates with hooks for automatic inference
* on document indexing.
*
* Part of the P2 (Adaptive Loops) ruvLLM feature set.
*/
import type { PluginLogger } from "clawdbot/plugin-sdk";
import type { RuvectorClient } from "../client.js";
import type { RuvectorDB } from "../db.js";
import type { EmbeddingProvider } from "../embeddings.js";
import type { VectorEntry } from "../types.js";
// =============================================================================
// Types
// =============================================================================
/**
* An extracted entity from content.
*/
export type ExtractedEntity = {
/** Entity text as found in content */
text: string;
/** Entity type/category */
type: EntityType;
/** Start position in content */
startPos: number;
/** End position in content */
endPos: number;
/** Confidence score (0-1) */
confidence: number;
/** Normalized form of the entity */
normalized?: string;
};
/**
* Entity types for classification.
*/
export type EntityType =
| "person"
| "organization"
| "location"
| "date"
| "time"
| "number"
| "url"
| "email"
| "concept"
| "action"
| "object"
| "unknown";
/**
* An inferred relationship between entities or documents.
*/
export type InferredRelationship = {
/** Source entity or document ID */
sourceId: string;
/** Source text (if entity) */
sourceText?: string;
/** Target entity or document ID */
targetId: string;
/** Target text (if entity) */
targetText?: string;
/** Relationship type */
relationshipType: RelationshipType;
/** Confidence score (0-1) */
confidence: number;
/** Evidence/reason for this relationship */
evidence?: string;
};
/**
* Types of relationships that can be inferred.
*/
export type RelationshipType =
| "MENTIONS"
| "RELATED_TO"
| "SIMILAR_TO"
| "FOLLOWS"
| "REFERENCES"
| "CONTAINS"
| "CAUSED_BY"
| "AFFECTS"
| "LOCATED_IN"
| "BELONGS_TO"
| "PART_OF"
| "SAME_AS";
/**
* Options for relationship inference.
*/
export type InferenceOptions = {
/** Minimum similarity for auto-linking (default: 0.7) */
similarityThreshold?: number;
/** Maximum relationships to create per document (default: 10) */
maxRelationships?: number;
/** Entity types to extract (default: all) */
entityTypes?: EntityType[];
/** Whether to create bidirectional links (default: false) */
bidirectional?: boolean;
};
/**
* Result from inference operations.
*/
export type InferenceResult = {
/** Entities extracted from content */
entities: ExtractedEntity[];
/** Relationships inferred */
relationships: InferredRelationship[];
/** Number of graph edges created */
edgesCreated: number;
/** Processing time in milliseconds */
processingTimeMs: number;
};
// =============================================================================
// RelationshipInferrer Class
// =============================================================================
/**
* Automatic relationship inference engine.
*
* Features:
* - Entity extraction using pattern matching
* - Relationship detection from content structure
* - Automatic linking based on vector similarity
* - Integration with hooks for on-index inference
*
* @example
* ```typescript
* const inferrer = new RelationshipInferrer({
* client,
* db,
* embeddings,
* logger,
* });
*
* // Infer from new content
* const result = await inferrer.inferFromContent(entry);
*
* // Auto-link by similarity
* const links = await inferrer.linkSimilar(entryId, 0.8);
* ```
*/
export class RelationshipInferrer {
private readonly client: RuvectorClient;
private readonly db: RuvectorDB;
private readonly embeddings: EmbeddingProvider;
private readonly logger: PluginLogger;
// Entity extraction patterns
private readonly patterns: Map<EntityType, RegExp[]> = new Map();
constructor(options: {
client: RuvectorClient;
db: RuvectorDB;
embeddings: EmbeddingProvider;
logger: PluginLogger;
}) {
this.client = options.client;
this.db = options.db;
this.embeddings = options.embeddings;
this.logger = options.logger;
this.initializePatterns();
}
// ===========================================================================
// Core Methods
// ===========================================================================
/**
* Infer relationships from a document entry.
*
* This method:
* 1. Extracts entities from the content
* 2. Detects relationships between entities
* 3. Creates graph edges for discovered relationships
*
* @param entry - The vector entry to analyze
* @param options - Inference options
* @returns Inference results including entities and relationships
*/
async inferFromContent(
entry: VectorEntry,
options: InferenceOptions = {},
): Promise<InferenceResult> {
const startTime = Date.now();
const maxRelationships = options.maxRelationships ?? 10;
try {
const content = entry.metadata.text;
if (!content || typeof content !== "string") {
return {
entities: [],
relationships: [],
edgesCreated: 0,
processingTimeMs: Date.now() - startTime,
};
}
// Step 1: Extract entities from content
const entities = this.extractEntities(content, options.entityTypes);
// Step 2: Detect relationships between entities
const entityRelationships = this.detectEntityRelationships(
content,
entities,
);
// Step 3: Create graph edges for entity relationships
let edgesCreated = 0;
for (const rel of entityRelationships.slice(0, maxRelationships)) {
try {
const created = await this.createRelationshipEdge(entry.id, rel);
if (created) edgesCreated++;
} catch (err) {
this.logger.debug?.(
`relationship-inferrer: failed to create edge: ${formatError(err)}`,
);
}
}
const result: InferenceResult = {
entities,
relationships: entityRelationships,
edgesCreated,
processingTimeMs: Date.now() - startTime,
};
this.logger.debug?.(
`relationship-inferrer: inferred ${entities.length} entities, ` +
`${entityRelationships.length} relationships from entry ${entry.id} ` +
`(${result.processingTimeMs}ms)`,
);
return result;
} catch (err) {
this.logger.warn(
`relationship-inferrer: inferFromContent failed: ${formatError(err)}`,
);
return {
entities: [],
relationships: [],
edgesCreated: 0,
processingTimeMs: Date.now() - startTime,
};
}
}
/**
* Automatically link a document to similar documents by vector similarity.
*
* @param entryId - The document ID to find links for
* @param threshold - Minimum similarity threshold (default: 0.7)
* @returns Number of edges created
*/
async linkSimilar(
entryId: string,
threshold?: number,
): Promise<number> {
const similarityThreshold = threshold ?? 0.7;
try {
// Get the entry to link
const entry = await this.client.get(entryId);
if (!entry || entry.vector.length === 0) {
this.logger.debug?.(
`relationship-inferrer: entry ${entryId} not found or has no vector`,
);
return 0;
}
// Search for similar entries
const searchResults = await this.client.search({
vector: entry.vector,
limit: 20,
minScore: similarityThreshold,
});
let edgesCreated = 0;
for (const result of searchResults) {
// Skip self
if (result.entry.id === entryId) continue;
// Create SIMILAR_TO relationship
try {
const edgeId = await this.client.addEdge({
sourceId: entryId,
targetId: result.entry.id,
relationship: "SIMILAR_TO",
weight: result.score,
properties: {
similarity: result.score,
createdAt: Date.now(),
autoInferred: true,
},
});
if (edgeId) {
edgesCreated++;
}
} catch (err) {
// Edge might already exist, which is fine
this.logger.debug?.(
`relationship-inferrer: edge creation skipped: ${formatError(err)}`,
);
}
}
this.logger.debug?.(
`relationship-inferrer: created ${edgesCreated} similarity links for entry ${entryId}`,
);
return edgesCreated;
} catch (err) {
this.logger.warn(
`relationship-inferrer: linkSimilar failed for ${entryId}: ${formatError(err)}`,
);
return 0;
}
}
/**
* Batch process documents for relationship inference.
*
* @param entries - Documents to process
* @param options - Inference options
* @returns Total edges created
*/
async batchInfer(
entries: VectorEntry[],
options: InferenceOptions = {},
): Promise<number> {
let totalEdges = 0;
for (const entry of entries) {
const result = await this.inferFromContent(entry, options);
totalEdges += result.edgesCreated;
// Also link by similarity if graph is initialized
if (this.client.isGraphInitialized()) {
const similarEdges = await this.linkSimilar(
entry.id,
options.similarityThreshold,
);
totalEdges += similarEdges;
}
}
return totalEdges;
}
// ===========================================================================
// Entity Extraction
// ===========================================================================
/**
* Extract entities from text content.
*/
extractEntities(
content: string,
filterTypes?: EntityType[],
): ExtractedEntity[] {
const entities: ExtractedEntity[] = [];
const seenTexts = new Set<string>();
for (const [type, patterns] of this.patterns.entries()) {
// Skip types not in filter
if (filterTypes && !filterTypes.includes(type)) continue;
for (const pattern of patterns) {
// Ensure global flag is set without duplicating it
const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
const regex = new RegExp(pattern.source, flags);
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
const text = match[0].trim();
// Skip duplicates
const key = `${type}:${text.toLowerCase()}`;
if (seenTexts.has(key)) continue;
seenTexts.add(key);
// Skip very short or very long entities
if (text.length < 2 || text.length > 100) continue;
entities.push({
text,
type,
startPos: match.index,
endPos: match.index + match[0].length,
confidence: this.calculateEntityConfidence(text, type),
normalized: this.normalizeEntity(text, type),
});
}
}
}
// Sort by position in text
entities.sort((a, b) => a.startPos - b.startPos);
return entities;
}
// ===========================================================================
// Relationship Detection
// ===========================================================================
/**
* Detect relationships between extracted entities.
*/
private detectEntityRelationships(
content: string,
entities: ExtractedEntity[],
): InferredRelationship[] {
const relationships: InferredRelationship[] = [];
// Co-occurrence based relationships
for (let i = 0; i < entities.length; i++) {
for (let j = i + 1; j < entities.length; j++) {
const e1 = entities[i];
const e2 = entities[j];
// Check if entities are close in text (within 100 chars)
const distance = e2.startPos - e1.endPos;
if (distance > 0 && distance < 100) {
const relType = this.inferRelationshipType(content, e1, e2);
const confidence = this.calculateRelationshipConfidence(e1, e2, distance);
if (confidence > 0.3) {
relationships.push({
sourceId: e1.normalized ?? e1.text,
sourceText: e1.text,
targetId: e2.normalized ?? e2.text,
targetText: e2.text,
relationshipType: relType,
confidence,
evidence: content.slice(
Math.max(0, e1.startPos - 20),
Math.min(content.length, e2.endPos + 20),
),
});
}
}
}
}
// Sort by confidence descending
relationships.sort((a, b) => b.confidence - a.confidence);
return relationships;
}
/**
* Infer relationship type from context between two entities.
*/
private inferRelationshipType(
content: string,
e1: ExtractedEntity,
e2: ExtractedEntity,
): RelationshipType {
const between = content.slice(e1.endPos, e2.startPos).toLowerCase();
// Check for specific relationship indicators
if (/\b(in|at|from|to)\b/.test(between) && e2.type === "location") {
return "LOCATED_IN";
}
if (/\b(of|belongs to|part of|member of)\b/.test(between)) {
return "BELONGS_TO";
}
if (/\b(contains|includes|has)\b/.test(between)) {
return "CONTAINS";
}
if (/\b(causes|leads to|results in)\b/.test(between)) {
return "CAUSED_BY";
}
if (/\b(affects|impacts|influences)\b/.test(between)) {
return "AFFECTS";
}
if (/\b(mentions|refers to|about)\b/.test(between)) {
return "MENTIONS";
}
if (/\b(same as|equals|is)\b/.test(between)) {
return "SAME_AS";
}
// Default based on entity types
if (e1.type === "person" && e2.type === "organization") {
return "BELONGS_TO";
}
if (e1.type === "action" || e2.type === "action") {
return "AFFECTS";
}
return "RELATED_TO";
}
/**
* Calculate confidence for a relationship.
*/
private calculateRelationshipConfidence(
e1: ExtractedEntity,
e2: ExtractedEntity,
distance: number,
): number {
// Start with base confidence from entity confidences
let confidence = (e1.confidence + e2.confidence) / 2;
// Reduce confidence for distant entities
confidence *= Math.exp(-distance / 50);
// Boost for certain entity type combinations
if (
(e1.type === "person" && e2.type === "organization") ||
(e1.type === "person" && e2.type === "location") ||
(e1.type === "concept" && e2.type === "action")
) {
confidence *= 1.2;
}
return Math.min(1.0, confidence);
}
/**
* Calculate confidence score for an extracted entity.
*/
private calculateEntityConfidence(text: string, type: EntityType): number {
let confidence = 0.5; // Base confidence
// Boost for specific patterns
switch (type) {
case "email":
case "url":
confidence = 0.95; // High confidence for structural patterns
break;
case "date":
case "time":
case "number":
confidence = 0.9;
break;
case "person":
// Higher confidence for proper casing
if (/^[A-Z][a-z]+(\s+[A-Z][a-z]+)+$/.test(text)) {
confidence = 0.8;
}
break;
case "organization":
if (/\b(Inc|Corp|LLC|Ltd|Co)\b/i.test(text)) {
confidence = 0.85;
}
break;
default:
confidence = 0.5;
}
// Reduce confidence for very short entities
if (text.length < 4) {
confidence *= 0.7;
}
return confidence;
}
/**
* Normalize an entity to a canonical form.
*/
private normalizeEntity(text: string, type: EntityType): string {
switch (type) {
case "email":
return text.toLowerCase();
case "url":
return text.toLowerCase().replace(/^https?:\/\//, "").replace(/\/$/, "");
case "date":
// Try to parse and format date
try {
const date = new Date(text);
if (!isNaN(date.getTime())) {
return date.toISOString().split("T")[0];
}
} catch {
// Keep original
}
return text;
default:
// Title case for names
return text
.toLowerCase()
.replace(/\b\w/g, (c) => c.toUpperCase());
}
}
// ===========================================================================
// Graph Operations
// ===========================================================================
/**
* Create a relationship edge in the graph.
*/
private async createRelationshipEdge(
documentId: string,
relationship: InferredRelationship,
): Promise<boolean> {
if (!this.client.isGraphInitialized()) {
return false;
}
try {
// Create edge from document to target entity
await this.client.addEdge({
sourceId: documentId,
targetId: `entity:${relationship.targetId}`,
relationship: relationship.relationshipType,
weight: relationship.confidence,
properties: {
sourceText: relationship.sourceText,
targetText: relationship.targetText,
evidence: relationship.evidence,
confidence: relationship.confidence,
createdAt: Date.now(),
autoInferred: true,
},
});
return true;
} catch (err) {
this.logger.debug?.(
`relationship-inferrer: failed to create edge: ${formatError(err)}`,
);
return false;
}
}
// ===========================================================================
// Pattern Initialization
// ===========================================================================
/**
* Initialize entity extraction patterns.
*/
private initializePatterns(): void {
// Email pattern
this.patterns.set("email", [
/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/,
]);
// URL pattern
this.patterns.set("url", [
/https?:\/\/[^\s<>"{}|\\^`[\]]+/i,
/www\.[^\s<>"{}|\\^`[\]]+/i,
]);
// Date patterns
this.patterns.set("date", [
/\b\d{1,2}\/\d{1,2}\/\d{2,4}\b/,
/\b\d{4}-\d{2}-\d{2}\b/,
/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{1,2},?\s*\d{4}\b/i,
/\b\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{4}\b/i,
]);
// Time patterns
this.patterns.set("time", [
/\b\d{1,2}:\d{2}(?::\d{2})?\s*(?:AM|PM|am|pm)?\b/,
]);
// Number patterns (currency, percentages, quantities)
this.patterns.set("number", [
/\$[\d,]+(?:\.\d{2})?/,
/[\d,]+%/,
/\b\d+(?:,\d{3})*(?:\.\d+)?\s*(?:million|billion|thousand|hundred)\b/i,
]);
// Person names (simple heuristic: Title Case words)
this.patterns.set("person", [
/\b(?:Mr|Mrs|Ms|Dr|Prof)\.\s+[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*/,
/\b[A-Z][a-z]+\s+[A-Z][a-z]+(?:\s+[A-Z][a-z]+)?\b/,
]);
// Organization patterns
this.patterns.set("organization", [
/\b[A-Z][a-zA-Z]*(?:\s+[A-Z][a-zA-Z]*)*\s+(?:Inc|Corp|LLC|Ltd|Co|Company|Organization|Foundation|Institute)\b/,
/\b(?:The\s+)?[A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)+\b/,
]);
// Location patterns
this.patterns.set("location", [
/\b(?:in|at|from|to)\s+[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*\b/,
/\b[A-Z][a-z]+,\s+[A-Z]{2}\b/, // City, State
]);
// Concept patterns (abstract nouns, often quoted or emphasized)
// Limit quoted strings to reasonable length (2-50 chars) to avoid noise
this.patterns.set("concept", [
/"[^"]{2,50}"/,
/'[^']{2,50}'/,
/\b[a-z]+(?:tion|ment|ness|ity|ism)\b/,
]);
// Action patterns (verbs in gerund or infinitive form, with minimum length)
// Require at least 5 characters to avoid matching common short words
this.patterns.set("action", [
/\b(?:to\s+)[a-z]{3,}(?:ing|ed|e)?\b/,
/\b[a-z]{4,}(?:ing|ed)\b/,
]);
}
}
// =============================================================================
// Utility Functions
// =============================================================================
/**
* Format an error for logging.
*/
function formatError(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
return String(err);
}