feat: port MCP retrieval and write hardening
- align os MCP/runtime/docs with ra-h contract - add safe direct node lookup and context-optional flows - gate edge and context writes behind confirmation
This commit is contained in:
+82
-170
@@ -1,24 +1,24 @@
|
||||
/**
|
||||
* Auto-Edge Creation Service
|
||||
* Potential edge suggestion helper.
|
||||
*
|
||||
* After Quick Capture creates a node, this service:
|
||||
* 1. Extracts candidate entity strings from the node's description
|
||||
* 2. Looks up existing entity nodes by exact title match
|
||||
* 3. Creates edges with explanations for matches
|
||||
*
|
||||
* This is a "fast path" for obvious connections only - conservative by design.
|
||||
* This module no longer writes edges automatically. It only identifies
|
||||
* obvious connection candidates so an agent or UI can propose them first.
|
||||
*/
|
||||
|
||||
import { nodeService, edgeService } from '@/services/database';
|
||||
import { nodeService } from '@/services/database';
|
||||
import { Node } from '@/types/database';
|
||||
|
||||
/**
|
||||
* Clean up a candidate entity string by removing common prefixes/suffixes.
|
||||
*/
|
||||
export interface PotentialEdgeSuggestion {
|
||||
from_node_id: number;
|
||||
to_node_id: number;
|
||||
to_node_title: string;
|
||||
explanation: string;
|
||||
candidate_text: string;
|
||||
}
|
||||
|
||||
function cleanEntityCandidate(candidate: string): string {
|
||||
let cleaned = candidate.trim();
|
||||
|
||||
// Remove common author/attribution prefixes
|
||||
const prefixPatterns = [
|
||||
/^by\s+/i,
|
||||
/^author:\s*/i,
|
||||
@@ -37,65 +37,8 @@ function cleanEntityCandidate(candidate: string): string {
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract candidate entity strings from text using conservative heuristics.
|
||||
* Returns proper names, quoted titles, and recognized patterns.
|
||||
*/
|
||||
function extractCandidateEntities(text: string): string[] {
|
||||
if (!text || typeof text !== 'string') return [];
|
||||
|
||||
const candidates: Set<string> = new Set();
|
||||
|
||||
// Pattern 1: "By [Name]" pattern - common in article descriptions
|
||||
// Matches: "By Simon Willison", "by Sam Altman"
|
||||
const byPattern = /\b[Bb]y\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})\b/g;
|
||||
let match;
|
||||
while ((match = byPattern.exec(text)) !== null) {
|
||||
const name = match[1].trim();
|
||||
if (name.length >= 4 && !isGenericPhrase(name)) {
|
||||
candidates.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 2: Proper name sequences (2-4 capitalized words)
|
||||
// Matches: "Sam Altman", "Dario Amodei", "Peter Thiel"
|
||||
const properNamePattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})\b/g;
|
||||
while ((match = properNamePattern.exec(text)) !== null) {
|
||||
const name = match[1].trim();
|
||||
// Clean the candidate (remove "By ", etc.)
|
||||
const cleaned = cleanEntityCandidate(name);
|
||||
if (cleaned.length >= 4 && !isGenericPhrase(cleaned)) {
|
||||
candidates.add(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 3: Quoted titles (single or double quotes)
|
||||
// Matches: "Zero to One", 'The Lean Startup'
|
||||
const quotedPattern = /["']([^"']{3,60})["']/g;
|
||||
while ((match = quotedPattern.exec(text)) !== null) {
|
||||
const title = match[1].trim();
|
||||
if (title.length >= 3 && !isGenericPhrase(title)) {
|
||||
candidates.add(title);
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 4: Known organization patterns
|
||||
// Matches: OpenAI, DeepMind, Y Combinator, Fly.io
|
||||
const orgPattern = /\b(OpenAI|DeepMind|Anthropic|Google|Microsoft|Meta|Apple|Amazon|Y Combinator|YC|Stripe|Coinbase|Fly\.io|Vercel|Cloudflare)\b/gi;
|
||||
while ((match = orgPattern.exec(text)) !== null) {
|
||||
candidates.add(match[1]);
|
||||
}
|
||||
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a phrase is too generic to be a useful entity reference.
|
||||
*/
|
||||
function isGenericPhrase(phrase: string): boolean {
|
||||
const normalized = phrase.toLowerCase();
|
||||
|
||||
// Common stopwords and generic terms
|
||||
const genericTerms = [
|
||||
'the author', 'the article', 'the book', 'the podcast',
|
||||
'this article', 'this book', 'this podcast', 'this paper',
|
||||
@@ -105,30 +48,58 @@ function isGenericPhrase(phrase: string): boolean {
|
||||
'united states', 'new york', 'san francisco', 'silicon valley'
|
||||
];
|
||||
|
||||
return genericTerms.some(term => normalized === term || normalized.startsWith(term + ' '));
|
||||
return genericTerms.some(term => normalized === term || normalized.startsWith(`${term} `));
|
||||
}
|
||||
|
||||
function extractCandidateEntities(text: string): string[] {
|
||||
if (!text || typeof text !== 'string') return [];
|
||||
|
||||
const candidates: Set<string> = new Set();
|
||||
|
||||
const byPattern = /\b[Bb]y\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})\b/g;
|
||||
let match;
|
||||
while ((match = byPattern.exec(text)) !== null) {
|
||||
const name = match[1].trim();
|
||||
if (name.length >= 4 && !isGenericPhrase(name)) {
|
||||
candidates.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
const properNamePattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})\b/g;
|
||||
while ((match = properNamePattern.exec(text)) !== null) {
|
||||
const cleaned = cleanEntityCandidate(match[1].trim());
|
||||
if (cleaned.length >= 4 && !isGenericPhrase(cleaned)) {
|
||||
candidates.add(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
const quotedPattern = /["']([^"']{3,60})["']/g;
|
||||
while ((match = quotedPattern.exec(text)) !== null) {
|
||||
const title = match[1].trim();
|
||||
if (title.length >= 3 && !isGenericPhrase(title)) {
|
||||
candidates.add(title);
|
||||
}
|
||||
}
|
||||
|
||||
const orgPattern = /\b(OpenAI|DeepMind|Anthropic|Google|Microsoft|Meta|Apple|Amazon|Y Combinator|YC|Stripe|Coinbase|Fly\.io|Vercel|Cloudflare)\b/gi;
|
||||
while ((match = orgPattern.exec(text)) !== null) {
|
||||
candidates.add(match[1]);
|
||||
}
|
||||
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up existing nodes that match candidate entity strings.
|
||||
* Uses exact title matching (case-insensitive).
|
||||
*/
|
||||
async function findMatchingEntityNodes(candidates: string[]): Promise<Map<string, Node>> {
|
||||
const matches = new Map<string, Node>();
|
||||
|
||||
if (candidates.length === 0) return matches;
|
||||
|
||||
// Get all nodes (we'll filter in memory for exact title matches)
|
||||
// In a larger system, we'd use a more efficient query
|
||||
const allNodes = await nodeService.getNodes({ limit: 10000 });
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalizedCandidate = candidate.toLowerCase().trim();
|
||||
|
||||
// Find exact title match (case-insensitive)
|
||||
const matchingNode = allNodes.find(node => {
|
||||
const matchingNode = allNodes.find((node) => {
|
||||
const normalizedTitle = (node.title || '').toLowerCase().trim();
|
||||
if (normalizedTitle !== normalizedCandidate) return false;
|
||||
return node.title.length < 80;
|
||||
return normalizedTitle === normalizedCandidate && node.title.length < 80;
|
||||
});
|
||||
|
||||
if (matchingNode) {
|
||||
@@ -139,95 +110,36 @@ async function findMatchingEntityNodes(candidates: string[]): Promise<Map<string
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create edges from a new node to matched entity nodes.
|
||||
* Each edge includes an explanation for auditability.
|
||||
*/
|
||||
async function createAutoEdges(
|
||||
newNodeId: number,
|
||||
matches: Map<string, Node>
|
||||
): Promise<number> {
|
||||
let edgesCreated = 0;
|
||||
export async function suggestPotentialEdgesForNode(nodeId: number): Promise<PotentialEdgeSuggestion[]> {
|
||||
const node = await nodeService.getNodeById(nodeId);
|
||||
if (!node) {
|
||||
console.warn(`[autoEdge] Node ${nodeId} not found, skipping suggestion lookup`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const description = node.description || '';
|
||||
if (!description || description.length < 10) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates = extractCandidateEntities(description);
|
||||
if (candidates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matches = await findMatchingEntityNodes(candidates);
|
||||
const suggestions: PotentialEdgeSuggestion[] = [];
|
||||
|
||||
for (const [candidateText, entityNode] of matches) {
|
||||
// Skip self-references
|
||||
if (entityNode.id === newNodeId) continue;
|
||||
|
||||
// Check if edge already exists
|
||||
const exists = await edgeService.edgeExists(newNodeId, entityNode.id);
|
||||
if (exists) continue;
|
||||
|
||||
try {
|
||||
await edgeService.createEdge({
|
||||
from_node_id: newNodeId,
|
||||
to_node_id: entityNode.id,
|
||||
explanation: `Explicitly mentioned in description: "${candidateText}"`,
|
||||
created_via: 'quick_capture_auto',
|
||||
source: 'ai_similarity',
|
||||
skip_inference: false, // Let Idea Genealogy classify the relationship
|
||||
});
|
||||
edgesCreated++;
|
||||
console.log(`[autoEdge] Created edge: ${newNodeId} → ${entityNode.id} (${entityNode.title})`);
|
||||
} catch (error) {
|
||||
console.warn(`[autoEdge] Failed to create edge to ${entityNode.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return edgesCreated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point: Run auto-edge creation for a newly created node.
|
||||
* This is designed to be called fire-and-forget (non-blocking).
|
||||
*/
|
||||
export async function runAutoEdgeCreation(nodeId: number): Promise<void> {
|
||||
try {
|
||||
// Fetch the newly created node
|
||||
const node = await nodeService.getNodeById(nodeId);
|
||||
if (!node) {
|
||||
console.warn(`[autoEdge] Node ${nodeId} not found, skipping auto-edge creation`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use description as the source of truth for entity extraction
|
||||
const description = node.description || '';
|
||||
if (!description || description.length < 10) {
|
||||
console.log(`[autoEdge] Node ${nodeId} has no/short description, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract candidate entities from description
|
||||
const candidates = extractCandidateEntities(description);
|
||||
if (candidates.length === 0) {
|
||||
console.log(`[autoEdge] No entity candidates found in node ${nodeId} description`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[autoEdge] Found ${candidates.length} candidates for node ${nodeId}:`, candidates);
|
||||
|
||||
// Find matching existing nodes
|
||||
const matches = await findMatchingEntityNodes(candidates);
|
||||
if (matches.size === 0) {
|
||||
console.log(`[autoEdge] No matching entity nodes found for node ${nodeId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create edges
|
||||
const edgesCreated = await createAutoEdges(nodeId, matches);
|
||||
console.log(`[autoEdge] Created ${edgesCreated} auto-edges for node ${nodeId}`);
|
||||
} catch (error) {
|
||||
console.error(`[autoEdge] Error in auto-edge creation for node ${nodeId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule auto-edge creation to run asynchronously (fire-and-forget).
|
||||
* Use this from the nodes API to avoid blocking the response.
|
||||
*/
|
||||
export function scheduleAutoEdgeCreation(nodeId: number): void {
|
||||
setImmediate(() => {
|
||||
runAutoEdgeCreation(nodeId).catch(error => {
|
||||
console.error(`[autoEdge] Scheduled auto-edge creation failed for node ${nodeId}:`, error);
|
||||
if (entityNode.id === nodeId) continue;
|
||||
suggestions.push({
|
||||
from_node_id: nodeId,
|
||||
to_node_id: entityNode.id,
|
||||
to_node_title: entityNode.title,
|
||||
explanation: `Explicitly mentioned in description: "${candidateText}"`,
|
||||
candidate_text: candidateText,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
@@ -234,6 +234,7 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : `Failed to execute ${toolName}`;
|
||||
const capturedAt = new Date().toISOString();
|
||||
const title = deriveFallbackLinkTitle(url);
|
||||
const description =
|
||||
`Link record for this source. RA-H could not correctly process the URL during ingestion because ${message}. Stored so the source is not lost and can be revisited later.`;
|
||||
@@ -257,10 +258,15 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
|
||||
captured_method: 'quick_add_link_fallback',
|
||||
captured_by: 'human',
|
||||
source_metadata: {
|
||||
capture_origin: 'quick_add',
|
||||
capture_path: 'quick_add_link_fallback',
|
||||
explicit_capture: true,
|
||||
source_url: url,
|
||||
attempted_pipeline: type,
|
||||
extraction_failed: true,
|
||||
extraction_error: message,
|
||||
refined_at: new Date().toISOString(),
|
||||
captured_at: capturedAt,
|
||||
refined_at: capturedAt,
|
||||
},
|
||||
},
|
||||
context_id: contextId,
|
||||
@@ -296,6 +302,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
|
||||
throw new Error('Input is required to create a note');
|
||||
}
|
||||
|
||||
const capturedAt = new Date().toISOString();
|
||||
const title = deriveNoteTitle(content);
|
||||
const nodePayload: Record<string, unknown> = {
|
||||
title,
|
||||
@@ -307,7 +314,12 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
|
||||
captured_method: 'quick_add_note',
|
||||
captured_by: 'human',
|
||||
source_metadata: {
|
||||
refined_at: new Date().toISOString(),
|
||||
capture_origin: 'quick_add',
|
||||
capture_path: 'quick_add_note',
|
||||
explicit_capture: true,
|
||||
input_type: 'note',
|
||||
captured_at: capturedAt,
|
||||
refined_at: capturedAt,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -351,6 +363,7 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string, cont
|
||||
throw new Error('Input is required to import a chat transcript');
|
||||
}
|
||||
|
||||
const capturedAt = new Date().toISOString();
|
||||
const summaryResult = await summarizeTranscript(transcript);
|
||||
const baseSummary = summaryResult.summary?.trim() || 'Captured chat transcript. Review the raw transcript for full detail.';
|
||||
|
||||
@@ -395,7 +408,12 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string, cont
|
||||
transcript_length_chars: transcript.length,
|
||||
transcript_length_words: wordCount,
|
||||
transcript_truncated_for_summary: summaryResult.truncated ?? false,
|
||||
summary_generated_at: new Date().toISOString(),
|
||||
capture_origin: 'quick_add',
|
||||
capture_path: 'quick_add_chat',
|
||||
explicit_capture: true,
|
||||
input_type: 'chat',
|
||||
captured_at: capturedAt,
|
||||
summary_generated_at: capturedAt,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { contextService } from '@/services/database/contextService';
|
||||
import { nodeService } from '@/services/database/nodes';
|
||||
import { countHighSignalQueryTermMatches, getHighSignalSearchTerms, scoreNodeSearchMatch } from '@/services/database/searchRanking';
|
||||
import type { Node } from '@/types/database';
|
||||
|
||||
export interface DirectNodeLookupInput {
|
||||
search?: string;
|
||||
limit?: number;
|
||||
context_name?: string;
|
||||
contextId?: number;
|
||||
createdAfter?: string;
|
||||
createdBefore?: string;
|
||||
eventAfter?: string;
|
||||
eventBefore?: string;
|
||||
}
|
||||
|
||||
export interface DirectNodeLookupResult {
|
||||
nodes: Node[];
|
||||
count: number;
|
||||
filtersApplied: {
|
||||
search?: string;
|
||||
limit: number;
|
||||
context_name?: string;
|
||||
createdAfter?: string;
|
||||
createdBefore?: string;
|
||||
eventAfter?: string;
|
||||
eventBefore?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeContextName(value: string | undefined): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
async function resolveSearchContext(input: DirectNodeLookupInput): Promise<{ contextId?: number; context_name?: string }> {
|
||||
const normalizedName = normalizeContextName(input.context_name);
|
||||
if (normalizedName) {
|
||||
const context = await contextService.getContextByName(normalizedName);
|
||||
if (!context) {
|
||||
console.warn(`directNodeLookup received unknown context_name "${normalizedName}"; ignoring context filter.`);
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
contextId: context.id,
|
||||
context_name: context.name,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof input.contextId === 'number') {
|
||||
const context = await contextService.getContextById(input.contextId);
|
||||
if (!context) {
|
||||
console.warn(`directNodeLookup received invalid legacy contextId ${input.contextId}; ignoring context filter.`);
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
contextId: context.id,
|
||||
context_name: context.name,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function hasStrongAnchorMatch(nodes: Node[], searchTerm: string): boolean {
|
||||
if (!searchTerm || nodes.length === 0) return false;
|
||||
const highSignalTerms = getHighSignalSearchTerms(searchTerm);
|
||||
const requiredMatches = Math.min(2, highSignalTerms.length || 1);
|
||||
return nodes.some(node => countHighSignalQueryTermMatches(node, searchTerm) >= requiredMatches);
|
||||
}
|
||||
|
||||
export async function directNodeLookup(input: DirectNodeLookupInput): Promise<DirectNodeLookupResult> {
|
||||
const limit = Math.min(Math.max(input.limit ?? 10, 1), 50);
|
||||
const searchTerm = input.search?.trim();
|
||||
|
||||
if (searchTerm && /^\d+$/.test(searchTerm)) {
|
||||
const nodeId = Number(searchTerm);
|
||||
const node = await nodeService.getNodeById(nodeId);
|
||||
return {
|
||||
nodes: node ? [node] : [],
|
||||
count: node ? 1 : 0,
|
||||
filtersApplied: {
|
||||
search: searchTerm,
|
||||
limit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedContext = await resolveSearchContext(input);
|
||||
const effectiveFilters = {
|
||||
search: searchTerm,
|
||||
limit,
|
||||
contextId: resolvedContext.contextId,
|
||||
searchMode: 'standard' as const,
|
||||
createdAfter: input.createdAfter,
|
||||
createdBefore: input.createdBefore,
|
||||
eventAfter: input.eventAfter,
|
||||
eventBefore: input.eventBefore,
|
||||
};
|
||||
|
||||
let safeNodes = await nodeService.getNodes(effectiveFilters);
|
||||
|
||||
const hadExtraFilters = Boolean(
|
||||
effectiveFilters.contextId !== undefined ||
|
||||
effectiveFilters.createdAfter ||
|
||||
effectiveFilters.createdBefore ||
|
||||
effectiveFilters.eventAfter ||
|
||||
effectiveFilters.eventBefore
|
||||
);
|
||||
|
||||
if (searchTerm && hadExtraFilters && (safeNodes.length === 0 || !hasStrongAnchorMatch(safeNodes, searchTerm))) {
|
||||
console.warn(`directNodeLookup falling back to plain literal search for "${searchTerm}" after filtered lookup missed a strong anchor match.`);
|
||||
safeNodes = await nodeService.searchNodes(searchTerm, limit);
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
safeNodes = safeNodes
|
||||
.map(node => ({ node, score: scoreNodeSearchMatch(node, searchTerm) }))
|
||||
.sort((a, b) => b.score - a.score || b.node.updated_at.localeCompare(a.node.updated_at))
|
||||
.slice(0, limit)
|
||||
.map(entry => entry.node);
|
||||
} else {
|
||||
safeNodes = safeNodes.slice(0, limit);
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: safeNodes,
|
||||
count: safeNodes.length,
|
||||
filtersApplied: {
|
||||
search: searchTerm,
|
||||
limit,
|
||||
context_name: resolvedContext.context_name,
|
||||
createdAfter: input.createdAfter,
|
||||
createdBefore: input.createdBefore,
|
||||
eventAfter: input.eventAfter,
|
||||
eventBefore: input.eventBefore,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user