feat: sync runtime search and schema quality updates from app repo
- port retrieval, validation, and eval improvements relevant to os - align prompts and dimensions with the flat single-agent model - replace the old eval suite with the focused core scenarios Generated with Codex
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
|
||||
export const createDimensionTool = tool({
|
||||
description: 'Create a new dimension or update existing dimension. IMPORTANT: Always ask the user for a description explaining what belongs in this dimension before creating it. Dimensions without descriptions cannot be auto-assigned.',
|
||||
description: 'Create a new dimension. Always provide a description explaining what belongs in this category.',
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe('Dimension name'),
|
||||
description: z.string().min(1).max(500).describe('Dimension description explaining what content belongs in this dimension (required, max 500 characters)'),
|
||||
isPriority: z.boolean().optional().describe('Whether to lock this dimension for auto-assignment (default: false)')
|
||||
description: z.string().min(1).max(500).describe('Dimension description explaining what content belongs in this dimension (required, max 500 characters)')
|
||||
}),
|
||||
execute: async (params) => {
|
||||
console.log('📁 CreateDimension tool called with params:', JSON.stringify(params, null, 2));
|
||||
@@ -21,13 +21,12 @@ export const createDimensionTool = tool({
|
||||
}
|
||||
|
||||
// Call POST /api/dimensions
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions`, {
|
||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/dimensions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: trimmedName,
|
||||
description: params.description.trim(),
|
||||
isPriority: params.isPriority || false
|
||||
description: params.description.trim()
|
||||
})
|
||||
});
|
||||
|
||||
@@ -52,7 +51,7 @@ export const createDimensionTool = tool({
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
message: `Created dimension "${trimmedName}"${params.isPriority ? ' (locked)' : ''}${params.description ? ' with description' : ''}`
|
||||
message: `Created dimension "${trimmedName}"${params.description ? ' with description' : ''}`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -63,4 +62,3 @@ export const createDimensionTool = tool({
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -3,33 +3,24 @@ import { z } from 'zod';
|
||||
import { edgeService } from '@/services/database/edges';
|
||||
import { nodeService } from '@/services/database/nodes';
|
||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||
import { validateEdgeExplanation } from '@/services/database/quality';
|
||||
|
||||
export const createEdgeTool = tool({
|
||||
description:
|
||||
'Create directed relationship between nodes.\n\n' +
|
||||
'Direction rule: FROM node → TO node should read correctly.\n' +
|
||||
'Prefer the 4 core relations unless the user clearly wants an advanced intellectual relation:\n' +
|
||||
'- Made by → created_by (attribution)\n' +
|
||||
'- Part of → part_of (attribution)\n' +
|
||||
'- Came from → source_of (intellectual)\n' +
|
||||
'- Related → related_to (intellectual fallback)\n\n' +
|
||||
'Examples:\n' +
|
||||
'- Episode → Podcast: "Episode of this podcast"\n' +
|
||||
'- Book → Author: "Written by"\n' +
|
||||
'- Company → Founder: "Founded by"\n' +
|
||||
'- Insight → Source: "Came from / inspired by"\n',
|
||||
'Create a relationship between two nodes. Provide an explanation and the system will infer the type and direction.\n\n' +
|
||||
'Examples of explanations:\n' +
|
||||
'- "Written by" (book → author)\n' +
|
||||
'- "Episode of this podcast" (episode → podcast)\n' +
|
||||
'- "Inspired this insight" (source → derivative)\n' +
|
||||
'- "Related concept" (general relationship)\n',
|
||||
inputSchema: z.object({
|
||||
from_node_id: z.number().describe('The ID of the source node (where the connection originates)'),
|
||||
to_node_id: z.number().describe('The ID of the target node (where the connection points to)'),
|
||||
from_node_id: z.number().describe('The ID of the source node'),
|
||||
to_node_id: z.number().describe('The ID of the target node'),
|
||||
explanation: z.string().describe(
|
||||
'REQUIRED: Why does this connection exist? Be specific. ' +
|
||||
'Write it as a relationship that reads FROM → TO. ' +
|
||||
'Examples: "Author of this book", "Guest on this podcast", ' +
|
||||
'"Episode of this podcast", "This insight came from this podcast episode", "Extends the concept introduced here"'
|
||||
'REQUIRED: Why does this connection exist? The system will infer the relationship type from your explanation.'
|
||||
),
|
||||
source: z.enum(['user', 'ai', 'ai_similarity', 'helper_name']).default('ai').describe(
|
||||
'Source of this edge. Use "ai" (or "helper_name") for AI-created connections, ' +
|
||||
'"user" for manual connections, "ai_similarity" for similarity-based connections.'
|
||||
'Source of this edge. Use "ai" for AI-created, "user" for manual, "ai_similarity" for similarity-based.'
|
||||
)
|
||||
}),
|
||||
execute: async (params) => {
|
||||
@@ -69,6 +60,14 @@ export const createEdgeTool = tool({
|
||||
data: null
|
||||
};
|
||||
}
|
||||
const explanationError = validateEdgeExplanation(explanation);
|
||||
if (explanationError) {
|
||||
return {
|
||||
success: false,
|
||||
error: explanationError,
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
const [fromNode, toNode] = await Promise.all([
|
||||
nodeService.getNodeById(params.from_node_id),
|
||||
|
||||
@@ -1,34 +1,41 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||
import { normalizeDimensions, validateExplicitDescription } from '@/services/database/quality';
|
||||
|
||||
export const createNodeTool = tool({
|
||||
description: 'Create node with title/content/link and optional dimensions (locked dimensions auto-assigned)',
|
||||
description: 'Create node. Description is REQUIRED and must be explicit about what the thing is (podcast, chat summary, idea, etc).',
|
||||
inputSchema: z.object({
|
||||
title: z.string().describe('The title of the node'),
|
||||
description: z.string().max(280).optional().describe('WHAT this is + WHY it matters. Extremely concise. No "discusses/explores". Auto-generated if omitted.'),
|
||||
notes: z.string().optional().describe('The main notes or content for this node'),
|
||||
notes: z.string().optional().describe('User notes, analysis, or thoughts about this node'),
|
||||
description: z.string().max(280).describe('REQUIRED. Explicitly state WHAT this is (e.g. podcast episode, conversation summary, user insight) + WHY it matters for context grounding.'),
|
||||
link: z.string().optional().describe('A URL link to the source'),
|
||||
event_date: z.string().optional().describe('ISO date string for time-anchored nodes (e.g. meetings, events)'),
|
||||
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
|
||||
dimensions: z
|
||||
.array(z.string())
|
||||
.max(5)
|
||||
.optional()
|
||||
.describe('Optional dimension tags to apply to this node (0-5 items). Locked dimensions will be auto-assigned.'),
|
||||
.describe('Optional dimension tags to apply to this node (0-5 items).'),
|
||||
chunk: z.string().optional().describe('Raw content for later processing - CRITICAL for extracted content from URLs'),
|
||||
metadata: z.record(z.any()).optional().describe('Additional metadata like source info, extraction details, etc.')
|
||||
}),
|
||||
execute: async (params) => {
|
||||
console.log('🎯 CreateNode tool called with params:', JSON.stringify(params, null, 2));
|
||||
try {
|
||||
const rawDimensions = params.dimensions || [];
|
||||
const trimmedDimensions = rawDimensions
|
||||
.map(d => typeof d === 'string' ? d.trim() : '')
|
||||
.filter(Boolean)
|
||||
.slice(0, 5); // Limit to 5 dimensions max
|
||||
const descriptionError = validateExplicitDescription(params.description);
|
||||
if (descriptionError) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${descriptionError} Do not retry with minor rephrasing in the same turn. Rewrite the description so it explicitly names the entity type, such as note, node, person, episode, article, project, test node, or skill, and states why it matters.`,
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
const trimmedDimensions = normalizeDimensions(params.dimensions || [], 5);
|
||||
|
||||
// Call the nodes API endpoint
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...params, dimensions: trimmedDimensions })
|
||||
@@ -57,7 +64,7 @@ export const createNodeTool = tool({
|
||||
...result.data,
|
||||
formatted_display: formattedDisplay
|
||||
},
|
||||
message: `Created node ${formattedDisplay} with dimensions: ${result.data.dimensions ? result.data.dimensions.join(', ') : 'auto-assigned'}`
|
||||
message: `Created node ${formattedDisplay} with dimensions: ${result.data.dimensions ? result.data.dimensions.join(', ') : 'none'}`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DimensionService } from '@/services/database/dimensionService';
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
|
||||
export const getDimensionTool = tool({
|
||||
description: 'Get detailed information about a specific dimension by name, including its description, priority status, and node count.',
|
||||
description: 'Get dimension details: description and node count.',
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe('The exact name of the dimension to retrieve')
|
||||
}),
|
||||
@@ -43,7 +43,6 @@ export const getDimensionTool = tool({
|
||||
data: {
|
||||
name: trimmedName,
|
||||
description: null,
|
||||
isPriority: false,
|
||||
nodeCount,
|
||||
exists: true,
|
||||
hasMetadata: false
|
||||
@@ -62,7 +61,6 @@ export const getDimensionTool = tool({
|
||||
const result = {
|
||||
name: dimension.name,
|
||||
description: dimension.description,
|
||||
isPriority: dimension.is_priority,
|
||||
nodeCount,
|
||||
updatedAt: dimension.updated_at,
|
||||
exists: true,
|
||||
@@ -72,7 +70,6 @@ export const getDimensionTool = tool({
|
||||
// Build descriptive message
|
||||
const parts: string[] = [];
|
||||
parts.push(`Dimension: ${result.name}`);
|
||||
if (result.isPriority) parts.push('Status: 🔒 Priority (locked)');
|
||||
parts.push(`Nodes: ${result.nodeCount}`);
|
||||
if (result.description) parts.push(`Description: ${result.description}`);
|
||||
parts.push(`Last updated: ${result.updatedAt}`);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
|
||||
export const queryDimensionsTool = tool({
|
||||
description: 'Query dimensions with optional filtering by name, priority status, or search term. Returns dimensions with their node counts.',
|
||||
description: 'List dimensions with node counts. Use this to inspect the user\'s organizational categories.',
|
||||
inputSchema: z.object({
|
||||
filters: z.object({
|
||||
search: z.string().describe('Search term to match against dimension names').optional(),
|
||||
isPriority: z.boolean().describe('Filter by priority (locked) status').optional(),
|
||||
limit: z.number().min(1).max(100).default(50).describe('Maximum number of results to return')
|
||||
}).optional()
|
||||
}),
|
||||
@@ -14,7 +14,7 @@ export const queryDimensionsTool = tool({
|
||||
console.log('📁 QueryDimensions tool called with filters:', JSON.stringify(filters, null, 2));
|
||||
try {
|
||||
const limit = filters.limit || 50;
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||
const baseUrl = getInternalApiBaseUrl();
|
||||
|
||||
// Use existing API endpoint for dimension listing
|
||||
const response = await fetch(`${baseUrl}/api/dimensions/popular`);
|
||||
@@ -48,7 +48,6 @@ export const queryDimensionsTool = tool({
|
||||
let dimensions = result.data as Array<{
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
description: string | null;
|
||||
}>;
|
||||
|
||||
@@ -60,11 +59,6 @@ export const queryDimensionsTool = tool({
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by priority status
|
||||
if (filters.isPriority !== undefined) {
|
||||
dimensions = dimensions.filter(d => d.isPriority === filters.isPriority);
|
||||
}
|
||||
|
||||
// Apply limit
|
||||
const limitedDimensions = dimensions.slice(0, limit);
|
||||
|
||||
@@ -72,18 +66,16 @@ export const queryDimensionsTool = tool({
|
||||
const formattedDimensions = limitedDimensions.map(d => ({
|
||||
name: d.dimension,
|
||||
count: d.count,
|
||||
isPriority: d.isPriority,
|
||||
description: d.description
|
||||
}));
|
||||
|
||||
// Build message
|
||||
const filterParts: string[] = [];
|
||||
if (filters.search) filterParts.push(`matching "${filters.search}"`);
|
||||
if (filters.isPriority !== undefined) filterParts.push(filters.isPriority ? 'priority only' : 'non-priority only');
|
||||
const filterDesc = filterParts.length > 0 ? ` (${filterParts.join(', ')})` : '';
|
||||
|
||||
const dimensionList = formattedDimensions
|
||||
.map(d => `• ${d.name}${d.isPriority ? ' 🔒' : ''} (${d.count} nodes)${d.description ? ` - ${d.description}` : ''}`)
|
||||
.map(d => `• ${d.name} (${d.count} nodes)${d.description ? ` - ${d.description}` : ''}`)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,15 @@ import { z } from 'zod';
|
||||
import { edgeService } from '@/services/database/edges';
|
||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||
|
||||
function truncateText(value: unknown, maxLength = 180): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.length <= maxLength) return trimmed;
|
||||
if (maxLength <= 3) return trimmed.slice(0, maxLength);
|
||||
return `${trimmed.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
export const queryEdgeTool = tool({
|
||||
description: 'Find edges by node/direction/source/ID',
|
||||
inputSchema: z.object({
|
||||
@@ -38,6 +47,7 @@ export const queryEdgeTool = tool({
|
||||
|
||||
// Handle node connections (most common use case)
|
||||
if (filters.node_id) {
|
||||
const effectiveLimit = Math.min(filters.limit || 20, 12);
|
||||
const connections = await edgeService.getNodeConnections(filters.node_id);
|
||||
const edges = connections.map(conn => conn.edge);
|
||||
|
||||
@@ -48,33 +58,60 @@ export const queryEdgeTool = tool({
|
||||
}
|
||||
|
||||
// Apply limit and format connected nodes
|
||||
const limitedConnections = connections.slice(0, filters.limit || 20);
|
||||
const limitedConnections = connections.slice(0, effectiveLimit);
|
||||
const formattedConnections = limitedConnections.map(connection => {
|
||||
const formattedNode = formatNodeForChat({
|
||||
id: connection.connected_node.id,
|
||||
title: connection.connected_node.title,
|
||||
dimensions: connection.connected_node.dimensions || []
|
||||
});
|
||||
|
||||
const context = connection.edge.context as Record<string, unknown> | undefined;
|
||||
|
||||
return {
|
||||
...connection,
|
||||
edge: {
|
||||
id: connection.edge.id,
|
||||
from_node_id: connection.edge.from_node_id,
|
||||
to_node_id: connection.edge.to_node_id,
|
||||
source: connection.edge.source,
|
||||
created_at: connection.edge.created_at,
|
||||
context: {
|
||||
type: typeof context?.type === 'string' ? context.type : null,
|
||||
explanation: truncateText(context?.explanation),
|
||||
confidence: typeof context?.confidence === 'number' ? context.confidence : null,
|
||||
}
|
||||
},
|
||||
connected_node: {
|
||||
...connection.connected_node,
|
||||
id: connection.connected_node.id,
|
||||
title: connection.connected_node.title,
|
||||
description: truncateText(connection.connected_node.description, 140),
|
||||
dimensions: connection.connected_node.dimensions || [],
|
||||
formatted_display: formattedNode
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const summarizedEdges = formattedConnections.map(connection => ({
|
||||
id: connection.edge.id,
|
||||
from_node_id: connection.edge.from_node_id,
|
||||
to_node_id: connection.edge.to_node_id,
|
||||
source: connection.edge.source,
|
||||
created_at: connection.edge.created_at,
|
||||
context: connection.edge.context,
|
||||
connected_node: connection.connected_node.formatted_display,
|
||||
}));
|
||||
|
||||
// Create message with formatted connected nodes
|
||||
const connectedNodeLabels = formattedConnections.map(conn => conn.connected_node.formatted_display).join(', ');
|
||||
const message = `Found ${filteredEdges.length} edges for node ${filters.node_id}${connectedNodeLabels ? `. Connected nodes: ${connectedNodeLabels}` : ''}`;
|
||||
const message = `Found ${filteredEdges.length} edges for node ${filters.node_id}. Showing ${formattedConnections.length}${connectedNodeLabels ? `. Connected nodes: ${connectedNodeLabels}` : ''}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
edges: filteredEdges.slice(0, filters.limit || 20),
|
||||
edges: summarizedEdges,
|
||||
connections: formattedConnections,
|
||||
count: filteredEdges.length,
|
||||
returned_count: formattedConnections.length,
|
||||
filters_applied: filters
|
||||
},
|
||||
message: message
|
||||
|
||||
@@ -5,11 +5,11 @@ import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||
import type { Node } from '@/types/database';
|
||||
|
||||
export const queryNodesTool = tool({
|
||||
description: 'Search nodes by title/notes/dimensions',
|
||||
description: 'Search nodes across title, description, notes, and dimensions. Multi-word queries use FTS/tokenized fallback, and agent calls can add node-vector retrieval.',
|
||||
inputSchema: z.object({
|
||||
filters: z.object({
|
||||
dimensions: z.array(z.string()).describe('Filter by dimensions (e.g., ["research", "ai", "technology"]). Replaces old type/stage filtering.').optional(),
|
||||
search: z.string().describe('Search term to match against title or notes').optional(),
|
||||
search: z.string().describe('Search term to match against node title, description, or notes').optional(),
|
||||
limit: z.number().min(1).max(50).default(10).describe('Maximum number of results to return'),
|
||||
createdAfter: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'),
|
||||
createdBefore: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created before this date.'),
|
||||
@@ -73,6 +73,7 @@ export const queryNodesTool = tool({
|
||||
limit,
|
||||
dimensions: filters.dimensions,
|
||||
search: filters.search,
|
||||
searchMode: searchTerm ? 'hybrid' : 'standard',
|
||||
createdAfter: filters.createdAfter,
|
||||
createdBefore: filters.createdBefore,
|
||||
eventAfter: filters.eventAfter,
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
|
||||
export const updateDimensionTool = tool({
|
||||
description: 'Update dimension name, description, or lock status',
|
||||
description: 'Update a dimension name or description.',
|
||||
inputSchema: z.object({
|
||||
currentName: z.string().describe('Current dimension name'),
|
||||
newName: z.string().optional().describe('New dimension name (if renaming)'),
|
||||
description: z.string().max(500).optional().describe('New description (max 500 characters)'),
|
||||
isPriority: z.boolean().optional().describe('Lock/unlock status (true = locked, false = unlocked)')
|
||||
description: z.string().max(500).optional().describe('New description (max 500 characters)')
|
||||
}),
|
||||
execute: async (params) => {
|
||||
console.log('📝 UpdateDimension tool called with params:', JSON.stringify(params, null, 2));
|
||||
try {
|
||||
// Validate at least one update field
|
||||
if (!params.newName && params.description === undefined && params.isPriority === undefined) {
|
||||
if (!params.newName && params.description === undefined) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'At least one update field (newName, description, or isPriority) must be provided',
|
||||
error: 'At least one update field (newName or description) must be provided',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
@@ -31,11 +31,7 @@ export const updateDimensionTool = tool({
|
||||
body.newName = params.newName.trim();
|
||||
}
|
||||
|
||||
if (params.isPriority !== undefined) {
|
||||
body.isPriority = params.isPriority;
|
||||
}
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions`, {
|
||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/dimensions`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
@@ -62,7 +58,6 @@ export const updateDimensionTool = tool({
|
||||
const updates = [];
|
||||
if (params.newName) updates.push(`renamed to "${params.newName}"`);
|
||||
if (params.description !== undefined) updates.push('description updated');
|
||||
if (params.isPriority !== undefined) updates.push(params.isPriority ? 'locked' : 'unlocked');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -78,4 +73,3 @@ export const updateDimensionTool = tool({
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { edgeService } from '@/services/database/edges';
|
||||
import { validateEdgeExplanation } from '@/services/database/quality';
|
||||
|
||||
export const updateEdgeTool = tool({
|
||||
description: 'Update edge context/source',
|
||||
description: 'Update an edge explanation and/or source. Explanations must explicitly state the relationship.',
|
||||
inputSchema: z.object({
|
||||
edge_id: z.number().describe('The ID of the edge to update'),
|
||||
updates: z.object({
|
||||
explanation: z.string().optional().describe('Updated relationship explanation'),
|
||||
context: z.record(z.any()).optional().describe('Updated context information for this edge - can include explanation, relationship type, strength, notes, etc.'),
|
||||
source: z.enum(['user', 'ai_similarity', 'helper_name']).optional().describe('Updated source classification for this edge'),
|
||||
}).describe('Fields to update on the edge')
|
||||
@@ -38,6 +40,34 @@ export const updateEdgeTool = tool({
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof cleanUpdates.explanation === 'string') {
|
||||
const explanationError = validateEdgeExplanation(cleanUpdates.explanation);
|
||||
if (explanationError) {
|
||||
return {
|
||||
success: false,
|
||||
error: explanationError,
|
||||
data: existingEdge
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!cleanUpdates.explanation &&
|
||||
cleanUpdates.context &&
|
||||
typeof cleanUpdates.context === 'object' &&
|
||||
!Array.isArray(cleanUpdates.context) &&
|
||||
typeof cleanUpdates.context.explanation === 'string'
|
||||
) {
|
||||
const explanationError = validateEdgeExplanation(cleanUpdates.context.explanation);
|
||||
if (explanationError) {
|
||||
return {
|
||||
success: false,
|
||||
error: explanationError,
|
||||
data: existingEdge
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Update the edge
|
||||
const updatedEdge = await edgeService.updateEdge(params.edge_id, cleanUpdates);
|
||||
|
||||
@@ -45,7 +75,7 @@ export const updateEdgeTool = tool({
|
||||
const updateDescriptions = [];
|
||||
if (cleanUpdates.context) updateDescriptions.push('context');
|
||||
if (cleanUpdates.source) updateDescriptions.push(`source to ${cleanUpdates.source}`);
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: updatedEdge,
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
import { normalizeDimensions, validateExplicitDescription } from '@/services/database/quality';
|
||||
|
||||
export const updateNodeTool = tool({
|
||||
description: 'Update node fields',
|
||||
description: 'Update node fields. Description is REQUIRED on every update and must explicitly state WHAT this is + WHY it matters.',
|
||||
inputSchema: z.object({
|
||||
id: z.number().describe('The ID of the node to update'),
|
||||
updates: z.object({
|
||||
title: z.string().optional().describe('New title'),
|
||||
description: z.string().max(280).optional().describe('New description (overwrites existing). WHAT this is + WHY it matters. No "discusses/explores".'),
|
||||
notes: z.string().optional().describe('New notes (appended to existing)'),
|
||||
notes: z.string().optional().describe('User notes/analysis to append. USE THIS for workflow outputs, briefs, research notes, etc.'),
|
||||
description: z.string().max(280).describe('REQUIRED on every update. Explicitly state WHAT this is + WHY it matters. No "discusses/explores".'),
|
||||
link: z.string().optional().describe('New link'),
|
||||
event_date: z.string().optional().describe('ISO date string for time-anchored nodes'),
|
||||
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
|
||||
dimensions: z.array(z.string()).optional().describe('New dimension tags - completely replaces existing dimensions'),
|
||||
chunk: z.string().optional().describe('New chunk content'),
|
||||
chunk: z.string().optional().describe('DO NOT USE - raw source text that triggers re-embedding. Only for source corrections.'),
|
||||
metadata: z.record(z.any()).optional().describe('New metadata - completely replaces existing metadata')
|
||||
}).describe('Object containing the fields to update')
|
||||
}).describe('Object containing the fields to update. For adding notes/analysis, always use "notes" field.')
|
||||
}),
|
||||
execute: async ({ id, updates }) => {
|
||||
try {
|
||||
@@ -26,24 +28,40 @@ export const updateNodeTool = tool({
|
||||
};
|
||||
}
|
||||
|
||||
// FORCE APPEND for content field - fetch existing and append new content
|
||||
if (!updates.description) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Every node update requires an explicit description (WHAT this is + WHY it matters).',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
const descriptionError = validateExplicitDescription(updates.description);
|
||||
if (descriptionError) {
|
||||
return {
|
||||
success: false,
|
||||
error: descriptionError,
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
// FORCE APPEND for notes field - fetch existing and append new notes
|
||||
if (updates.notes) {
|
||||
const fetchResponse = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes/${id}`);
|
||||
const fetchResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes/${id}`);
|
||||
if (fetchResponse.ok) {
|
||||
const { node } = await fetchResponse.json();
|
||||
const existingNotes = (node?.notes || '').trim();
|
||||
const newNotes = updates.notes.trim();
|
||||
|
||||
// Skip if new content is identical to existing (model sent duplicate)
|
||||
|
||||
// Skip if new notes are identical to existing (model sent duplicate)
|
||||
if (existingNotes === newNotes) {
|
||||
console.log(`[updateNode] ERROR - new content identical to existing (${existingNotes.length} chars). Model should NOT call updateNode again.`);
|
||||
console.log(`[updateNode] ERROR - new notes identical to existing (${existingNotes.length} chars). Model should NOT call updateNode again.`);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Notes already up to date - do not call updateNode again. Move to next step.',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Detect if adding a section that already exists (e.g., ## Integration Analysis)
|
||||
const newSectionMatch = newNotes.match(/^##\s+(.+)$/m);
|
||||
if (newSectionMatch && existingNotes) {
|
||||
@@ -57,12 +75,12 @@ export const updateNodeTool = tool({
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Detect if model included existing content + new content
|
||||
|
||||
// Detect if model included existing notes + new notes
|
||||
if (existingNotes && newNotes.startsWith(existingNotes)) {
|
||||
// Extract only the new part
|
||||
const actualNewNotes = newNotes.substring(existingNotes.length).trim();
|
||||
console.log(`[updateNode] Model included existing content - extracting new part only (${actualNewNotes.length} chars)`);
|
||||
console.log(`[updateNode] Model included existing notes - extracting new part only (${actualNewNotes.length} chars)`);
|
||||
const separator = existingNotes.endsWith('\n\n') ? '' : '\n\n';
|
||||
updates.notes = `${existingNotes}${separator}${actualNewNotes}`;
|
||||
} else if (existingNotes) {
|
||||
@@ -71,15 +89,17 @@ export const updateNodeTool = tool({
|
||||
updates.notes = `${existingNotes}${separator}${newNotes}`;
|
||||
console.log(`[updateNode] Appended notes: ${existingNotes.length} + ${newNotes.length} = ${updates.notes.length} chars`);
|
||||
} else {
|
||||
console.log(`[updateNode] No existing content, using new content as-is (${newNotes.length} chars)`);
|
||||
console.log(`[updateNode] No existing notes, using new notes as-is (${newNotes.length} chars)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No dimension validation - user has full control over dimensions
|
||||
if (Array.isArray(updates.dimensions)) {
|
||||
updates.dimensions = normalizeDimensions(updates.dimensions, 5);
|
||||
}
|
||||
|
||||
// Call the nodes API endpoint
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes/${id}`, {
|
||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { chunkService } from '@/services/database/chunks';
|
||||
import { EmbeddingService } from '@/services/embeddings';
|
||||
|
||||
export const searchContentEmbeddingsTool = tool({
|
||||
description: 'Semantic search over node chunks with text fallback for reliability',
|
||||
description: 'Search source chunks with hybrid retrieval: vector similarity plus FTS/keyword fallback merged for reliability.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('The search query to find semantically similar content'),
|
||||
limit: z.number().min(1).max(20).default(5).describe('Maximum number of results to return (default: 5)'),
|
||||
@@ -107,7 +107,7 @@ export const searchContentEmbeddingsTool = tool({
|
||||
searched_nodes: searchNodeIds || 'all',
|
||||
count: chunks.length,
|
||||
similarity_threshold,
|
||||
search_method: 'vector_search',
|
||||
search_method: query ? 'hybrid_vector_fts' : 'vector_search',
|
||||
search_time_ms: searchTime,
|
||||
suggestions: suggestions.length > 0 ? suggestions : undefined
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user