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:
@@ -7,7 +7,7 @@ import { validateEdgeExplanation } from '@/services/database/quality';
|
||||
|
||||
export const createEdgeTool = tool({
|
||||
description:
|
||||
'Create a relationship between two nodes. Provide an explanation and the system will infer the type and direction.\n\n' +
|
||||
'Create a relationship between two nodes only after the user has explicitly confirmed the proposed connection. Use this as the execution step after you surfaced candidate edges in plain language and got a clear yes. 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' +
|
||||
@@ -19,6 +19,9 @@ export const createEdgeTool = tool({
|
||||
explanation: z.string().describe(
|
||||
'REQUIRED: Why does this connection exist? The system will infer the relationship type from your explanation.'
|
||||
),
|
||||
confirmed_by_user: z.boolean().describe(
|
||||
'Must be true. Only create the edge after the user has explicitly approved this proposed relationship.'
|
||||
),
|
||||
source: z.enum(['user', 'ai', 'ai_similarity', 'helper_name']).default('ai').describe(
|
||||
'Source of this edge. Use "ai" for AI-created, "user" for manual, "ai_similarity" for similarity-based.'
|
||||
)
|
||||
@@ -27,6 +30,14 @@ export const createEdgeTool = tool({
|
||||
console.log('🔗 CreateEdge tool called with params:', JSON.stringify(params, null, 2));
|
||||
|
||||
try {
|
||||
if (!params.confirmed_by_user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'createEdge requires explicit user confirmation before writing the relationship.',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate basic IDs
|
||||
if (!Number.isFinite(params.from_node_id) || params.from_node_id <= 0) {
|
||||
return {
|
||||
|
||||
@@ -56,17 +56,16 @@ function inferSourceFromContext(params: { title: string; description?: string; s
|
||||
}
|
||||
|
||||
export const createNodeTool = tool({
|
||||
description: 'Create a node. Set context explicitly only when it is clear and useful; otherwise leave it blank. Focus on a clean title, a strong natural description, preserved source text, and the right metadata. When the node comes from the user\'s own idea, note, or dictated thought, preserve their actual wording in source with only minimal cleanup instead of flattening it into a summary. Do not block creation if the description is incomplete. If the description framing is materially inferred, create the node first and then invite one concise user correction pass.',
|
||||
description: 'Create a node after you have already decided this should be a net-new write. If the user explicitly asked to save or import something and duplicate/update checks are complete, write immediately. If you are only suggesting a save, propose the node first and wait for confirmation. Leave context blank by default. Only set context when the user explicitly wants one and it is clear and useful; when that happens, use context_name rather than a numeric ID. Focus on a clean title, a strong natural description that says what the thing is, preserved source text, and the right metadata. When the node comes from the user\'s own idea, note, or dictated thought, preserve their actual wording in source with only minimal cleanup instead of flattening it into a summary. Do not block creation if the description is incomplete. If the description framing is materially inferred, create the node first and then invite one concise user correction pass.',
|
||||
inputSchema: z.object({
|
||||
title: z.string().describe('The title of the node'),
|
||||
description: z.string().max(500).optional().describe('Optional natural description. If you have enough context, describe what this is, why it belongs in Brad\'s graph, and its current workflow status in normal prose. Do not use labels like WHAT:, WHY:, or STATUS:.'),
|
||||
source: z.string().optional().describe('Canonical source content for embedding. For external content, store the actual transcript/article/document text. For user-authored ideas or dictated notes, store the user\'s original wording as fully as possible with only minimal cleanup such as obvious whitespace or transcription artifacts. Do not replace raw user thinking with a thin summary.'),
|
||||
link: z.string().optional().describe('A URL link to the source'),
|
||||
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
|
||||
context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Use when the node clearly belongs to a known context.'),
|
||||
context_name: z.string().optional().describe('Optional convenience context name. Resolved to a stable context_id before persistence.'),
|
||||
context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this node assigned to a known context.'),
|
||||
metadata: z.record(z.any()).optional().describe('Optional node metadata. Use canonical keys when known: type, state, captured_method, captured_by, and source_metadata. Source-specific facts belong inside source_metadata.')
|
||||
}),
|
||||
}).passthrough(),
|
||||
execute: async (params, context) => {
|
||||
console.log('🎯 CreateNode tool called with params:', JSON.stringify(params, null, 2));
|
||||
try {
|
||||
@@ -76,7 +75,7 @@ export const createNodeTool = tool({
|
||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...params, source: canonicalSource })
|
||||
body: JSON.stringify({ ...params, source: canonicalSource ?? params.source })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { contextService } from '@/services/database';
|
||||
import { nodeService } from '@/services/database/nodes';
|
||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||
import type { Node } from '@/types/database';
|
||||
import { countHighSignalQueryTermMatches, getHighSignalSearchTerms, scoreNodeSearchMatch } from '@/services/database/searchRanking';
|
||||
import { directNodeLookup } from '@/services/retrieval/directNodeLookup';
|
||||
|
||||
type QueryNodeFilters = {
|
||||
contextId?: number;
|
||||
context_name?: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
createdAfter?: string;
|
||||
@@ -17,128 +15,34 @@ type QueryNodeFilters = {
|
||||
};
|
||||
|
||||
export const queryNodesTool = tool({
|
||||
description: 'Find specific existing nodes in the graph by searching title, description, and source. Use this first when the user is trying to locate a node they already created or a specific existing podcast, article, idea, person, project, or note. For broader current-turn grounding of a substantive question, use retrieveQueryContext instead. Leave contextId unset unless you know an actual context-table ID; never pass a hub node ID or arbitrary node ID as contextId.',
|
||||
description: 'Find specific existing nodes in the graph by searching title, description, and source. Use this first when the user is trying to locate a node they already created or a specific existing podcast, article, idea, person, project, or note. For broader current-turn grounding of a substantive question, use retrieveQueryContext instead. Leave context blank by default. If the user explicitly wants a context filter, use context_name rather than a numeric ID.',
|
||||
inputSchema: z.object({
|
||||
filters: z.object({
|
||||
contextId: z.number().int().positive().describe('Optional primary context filter.').optional(),
|
||||
context_name: z.string().describe('Optional primary context name filter. Use only when the user explicitly wants a context-specific lookup.').optional(),
|
||||
search: z.string().describe('Search term to match against node title, description, or source').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.'),
|
||||
eventAfter: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes with event_date on or after this date.'),
|
||||
eventBefore: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes with event_date before this date.'),
|
||||
}).optional()
|
||||
}).passthrough().optional()
|
||||
}),
|
||||
execute: async ({ filters = {} }: { filters?: QueryNodeFilters }) => {
|
||||
console.log('🔍 QueryNodes tool called with filters:', JSON.stringify(filters, null, 2));
|
||||
try {
|
||||
const limit = filters.limit || 10;
|
||||
|
||||
const searchTerm = filters.search?.trim();
|
||||
if (searchTerm && /^\d+$/.test(searchTerm)) {
|
||||
const nodeId = Number(searchTerm);
|
||||
const node = await nodeService.getNodeById(nodeId);
|
||||
if (!node) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
nodes: [],
|
||||
count: 0,
|
||||
filters_applied: filters,
|
||||
},
|
||||
message: `Found 0 nodes matching id ${nodeId}`,
|
||||
};
|
||||
}
|
||||
|
||||
const formatted = formatNodeForChat({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
nodes: [{
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
created_at: node.created_at,
|
||||
updated_at: node.updated_at,
|
||||
event_date: node.event_date ?? null,
|
||||
formatted_display: formatted,
|
||||
}],
|
||||
count: 1,
|
||||
filters_applied: filters,
|
||||
},
|
||||
message: `Found 1 node matching id ${nodeId}:\n${formatted}`,
|
||||
};
|
||||
}
|
||||
|
||||
const runQuery = async (queryFilters: typeof filters): Promise<Node[]> => {
|
||||
const timeoutPromise: Promise<Node[] | undefined> = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('QueryNodes timeout after 10 seconds')), 10000);
|
||||
});
|
||||
|
||||
const nodesPromise: Promise<Node[] | undefined> = nodeService.getNodes({
|
||||
limit,
|
||||
contextId: queryFilters.contextId,
|
||||
search: queryFilters.search,
|
||||
// Keep queryNodes literal-first. retrieveQueryContext is the broader semantic path.
|
||||
searchMode: 'standard',
|
||||
createdAfter: queryFilters.createdAfter,
|
||||
createdBefore: queryFilters.createdBefore,
|
||||
eventAfter: queryFilters.eventAfter,
|
||||
eventBefore: queryFilters.eventBefore,
|
||||
});
|
||||
|
||||
const nodes = await Promise.race<Node[] | undefined>([nodesPromise, timeoutPromise]);
|
||||
return Array.isArray(nodes) ? nodes : [];
|
||||
};
|
||||
|
||||
const effectiveFilters = { ...filters };
|
||||
if (effectiveFilters.contextId !== undefined) {
|
||||
const context = await contextService.getContextById(effectiveFilters.contextId);
|
||||
if (!context) {
|
||||
console.warn(`queryNodes received invalid contextId ${effectiveFilters.contextId}; ignoring context filter.`);
|
||||
delete effectiveFilters.contextId;
|
||||
}
|
||||
}
|
||||
|
||||
let safeNodes = await runQuery(effectiveFilters);
|
||||
|
||||
const hadExtraFilters = Boolean(
|
||||
effectiveFilters.contextId !== undefined ||
|
||||
effectiveFilters.createdAfter ||
|
||||
effectiveFilters.createdBefore ||
|
||||
effectiveFilters.eventAfter ||
|
||||
effectiveFilters.eventBefore
|
||||
);
|
||||
|
||||
const hasStrongAnchorMatch = (nodes: Node[]): 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);
|
||||
};
|
||||
|
||||
// Match the nav search behavior when the model overconstrains a direct lookup.
|
||||
// This prevents notes from disappearing behind synthetic date filters or weak filtered matches.
|
||||
if (searchTerm && hadExtraFilters && (safeNodes.length === 0 || !hasStrongAnchorMatch(safeNodes))) {
|
||||
console.warn(`queryNodes falling back to plain literal search for "${searchTerm}" after filtered lookup failed to return 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);
|
||||
}
|
||||
|
||||
const limitedNodes = safeNodes.slice(0, limit);
|
||||
const result = await directNodeLookup({
|
||||
search: filters.search,
|
||||
limit: filters.limit,
|
||||
context_name: filters.context_name,
|
||||
contextId: filters.contextId,
|
||||
createdAfter: filters.createdAfter,
|
||||
createdBefore: filters.createdBefore,
|
||||
eventAfter: filters.eventAfter,
|
||||
eventBefore: filters.eventBefore,
|
||||
});
|
||||
|
||||
// Format nodes for chat display
|
||||
const formattedNodes = limitedNodes.map(node => {
|
||||
const formattedNodes = result.nodes.map(node => {
|
||||
const formatted = formatNodeForChat({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
@@ -155,14 +59,14 @@ export const queryNodesTool = tool({
|
||||
|
||||
// Create message with formatted node labels only (no full node payload)
|
||||
const formattedLabels = formattedNodes.map(node => node.formatted_display).join(', ');
|
||||
const message = `Found ${safeNodes.length} nodes${effectiveFilters.search ? ` matching: "${effectiveFilters.search}"` : ''}${formattedLabels ? `:\n${formattedLabels}` : ''}`;
|
||||
const message = `Found ${result.count} nodes${result.filtersApplied.search ? ` matching: "${result.filtersApplied.search}"` : ''}${formattedLabels ? `:\n${formattedLabels}` : ''}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
nodes: formattedNodes,
|
||||
count: safeNodes.length,
|
||||
filters_applied: effectiveFilters
|
||||
count: result.count,
|
||||
filters_applied: result.filtersApplied
|
||||
},
|
||||
message: message
|
||||
};
|
||||
|
||||
@@ -4,8 +4,9 @@ import { edgeService } from '@/services/database/edges';
|
||||
import { validateEdgeExplanation } from '@/services/database/quality';
|
||||
|
||||
export const updateEdgeTool = tool({
|
||||
description: 'Update an edge explanation and/or source. Explanations must explicitly state the relationship.',
|
||||
description: 'Update an edge explanation and/or source only after the user explicitly confirmed the corrected relationship. Explanations must explicitly state the relationship.',
|
||||
inputSchema: z.object({
|
||||
confirmed_by_user: z.boolean().describe('Must be true. Reject the edge update otherwise.'),
|
||||
edge_id: z.number().describe('The ID of the edge to update'),
|
||||
updates: z.object({
|
||||
explanation: z.string().optional().describe('Updated relationship explanation'),
|
||||
@@ -17,6 +18,14 @@ export const updateEdgeTool = tool({
|
||||
console.log('📝 UpdateEdge tool called with params:', JSON.stringify(params, null, 2));
|
||||
|
||||
try {
|
||||
if (!params.confirmed_by_user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Edge updates require explicit user confirmation before writing to the graph.',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
// Validate that edge exists before updating
|
||||
const existingEdge = await edgeService.getEdgeById(params.edge_id);
|
||||
if (!existingEdge) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
|
||||
export const updateNodeTool = tool({
|
||||
description: 'Update node fields. Use this to enrich or correct nodes without losing canonical source content. Context is preserved unless context_id is supplied explicitly. When fixing a user-authored idea node, source should preserve the user\'s original wording as fully as possible. Never block an update because the description is incomplete. If the new description framing is materially inferred, complete the update and then invite one concise user feedback pass.',
|
||||
description: 'Update node fields when the existing node is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates should proceed once the target node is clear; if you are only proposing a change, ask first. Use this to enrich or correct nodes without losing canonical source content. Context is preserved unless the user explicitly wants it changed. When that happens, prefer context_name rather than a numeric ID. Use clear_context only when the user explicitly wants the context removed. When fixing a user-authored idea node, source should preserve the user\'s original wording as fully as possible. Never block an update because the description is incomplete. If the new description framing is materially inferred, complete the update and then invite one concise user feedback pass.',
|
||||
inputSchema: z.object({
|
||||
id: z.number().describe('The ID of the node to update'),
|
||||
updates: z.object({
|
||||
@@ -12,9 +12,10 @@ export const updateNodeTool = tool({
|
||||
source: z.string().optional().describe('Canonical source content for embedding. Use this to set or correct the raw source text. For user-authored ideas or dictated notes, preserve the user\'s original wording with only minimal cleanup rather than compressing it into a summary.'),
|
||||
link: z.string().optional().describe('New link'),
|
||||
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
|
||||
context_id: z.number().int().positive().nullable().optional().describe('Primary context ID. Omit to preserve the existing context. Use null only to clear it intentionally.'),
|
||||
context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants to assign this node to a known context.'),
|
||||
clear_context: z.boolean().optional().describe('Set true only when the user explicitly wants to remove the node context.'),
|
||||
metadata: z.record(z.any()).optional().describe('Metadata patch. It now merges with existing metadata instead of replacing the full blob. Use canonical keys: type, state, captured_method, captured_by, source_metadata.')
|
||||
}).describe('Object containing the fields to update. Derived analysis should be stored in a separate linked node, not appended to the source node.')
|
||||
}).passthrough().describe('Object containing the fields to update. Derived analysis should be stored in a separate linked node, not appended to the source node.')
|
||||
}),
|
||||
execute: async ({ id, updates }) => {
|
||||
try {
|
||||
|
||||
@@ -4,16 +4,16 @@ import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||
|
||||
export const writeContextTool = tool({
|
||||
description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this sparingly for unusually valuable context. Never call it unless the user has clearly said yes.',
|
||||
description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this for agent-suggested capture after you already proposed the node briefly and got a clear yes. Prefer ordinary create/update flows for explicit user-directed capture. Never call it unless the user has clearly said yes.',
|
||||
inputSchema: z.object({
|
||||
title: z.string().min(1).max(160).describe('Clear proposed node title.'),
|
||||
description: z.string().min(1).max(500).describe('Natural description of what this context is and why it matters.'),
|
||||
source: z.string().optional().describe('Optional source or verbatim user wording to preserve.'),
|
||||
context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID.'),
|
||||
context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this saved under a known context.'),
|
||||
metadata: z.record(z.any()).optional().describe('Optional metadata patch.'),
|
||||
confirmed_by_user: z.boolean().describe('Must be true. Reject the write otherwise.'),
|
||||
}),
|
||||
execute: async ({ title, description, source, context_id, metadata, confirmed_by_user }) => {
|
||||
}).passthrough(),
|
||||
execute: async ({ title, description, source, context_name, metadata, confirmed_by_user, ...legacyParams }) => {
|
||||
if (!confirmed_by_user) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -30,7 +30,10 @@ export const writeContextTool = tool({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
source: source?.trim() || undefined,
|
||||
context_id: context_id ?? null,
|
||||
context_name: context_name?.trim() || undefined,
|
||||
context_id: typeof legacyParams.context_id === 'number' || legacyParams.context_id === null
|
||||
? legacyParams.context_id
|
||||
: undefined,
|
||||
metadata: {
|
||||
captured_by: 'human',
|
||||
captured_method: 'write_context',
|
||||
|
||||
@@ -157,6 +157,7 @@ export const paperExtractTool = tool({
|
||||
const nodeTitle = title || result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`;
|
||||
const fallbackDescriptionLead = `PDF document titled "${nodeTitle}"`;
|
||||
const finalDescription = ensureNodeDescription(aiAnalysis?.nodeDescription, fallbackDescriptionLead);
|
||||
const capturedAt = new Date().toISOString();
|
||||
|
||||
const createResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||
method: 'POST',
|
||||
@@ -172,13 +173,18 @@ export const paperExtractTool = tool({
|
||||
captured_method: 'paper_extract',
|
||||
captured_by: 'human',
|
||||
source_metadata: {
|
||||
capture_origin: 'extraction',
|
||||
capture_path: 'paper_extract',
|
||||
explicit_capture: true,
|
||||
source_url: url,
|
||||
hostname: new URL(url).hostname,
|
||||
author: result.metadata?.author || result.metadata?.info?.Author,
|
||||
pages: result.metadata?.pages,
|
||||
file_size: result.metadata?.file_size,
|
||||
content_length: result.source.length,
|
||||
extraction_method: result.metadata?.extraction_method || 'python_pdfplumber',
|
||||
refined_at: new Date().toISOString(),
|
||||
captured_at: capturedAt,
|
||||
refined_at: capturedAt,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -162,6 +162,7 @@ export const websiteExtractTool = tool({
|
||||
const nodeTitle = title || result.metadata?.title || `Website: ${new URL(url).hostname}`;
|
||||
const fallbackDescriptionLead = `${contentType === 'tweet' ? 'Tweet' : 'Website article'} from ${result.metadata?.author || result.metadata?.site_name || new URL(url).hostname} titled "${nodeTitle}"`;
|
||||
const finalDescription = ensureNodeDescription(aiAnalysis?.nodeDescription, fallbackDescriptionLead);
|
||||
const capturedAt = new Date().toISOString();
|
||||
|
||||
const createResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||
method: 'POST',
|
||||
@@ -178,12 +179,17 @@ export const websiteExtractTool = tool({
|
||||
captured_method: 'website_extract',
|
||||
captured_by: 'human',
|
||||
source_metadata: {
|
||||
capture_origin: 'extraction',
|
||||
capture_path: 'website_extract',
|
||||
explicit_capture: true,
|
||||
source_url: url,
|
||||
hostname: new URL(url).hostname,
|
||||
author: result.metadata?.author,
|
||||
published_date: result.metadata?.published_date || result.metadata?.date,
|
||||
content_length: result.source.length,
|
||||
extraction_method: result.metadata?.extraction_method || 'python_beautifulsoup',
|
||||
refined_at: new Date().toISOString(),
|
||||
captured_at: capturedAt,
|
||||
refined_at: capturedAt,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -193,6 +193,7 @@ export const youtubeExtractTool = tool({
|
||||
const transcriptSummary = await summariseTranscript(nodeTitle, result.source);
|
||||
const fallbackDescriptionLead = `YouTube video from ${result.metadata?.channel_name || 'an unknown channel'} titled "${nodeTitle}"`;
|
||||
const finalDescription = ensureNodeDescription(aiAnalysis?.nodeDescription, fallbackDescriptionLead);
|
||||
const capturedAt = new Date().toISOString();
|
||||
|
||||
const createResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||
method: 'POST',
|
||||
@@ -208,6 +209,10 @@ export const youtubeExtractTool = tool({
|
||||
captured_method: 'youtube_extract',
|
||||
captured_by: 'human',
|
||||
source_metadata: {
|
||||
capture_origin: 'extraction',
|
||||
capture_path: 'youtube_extract',
|
||||
explicit_capture: true,
|
||||
source_url: url,
|
||||
video_id: result.metadata?.video_id,
|
||||
channel_name: result.metadata?.channel_name,
|
||||
channel_url: result.metadata?.channel_url,
|
||||
@@ -218,7 +223,8 @@ export const youtubeExtractTool = tool({
|
||||
extraction_method: result.metadata?.extraction_method,
|
||||
summary_origin: transcriptSummary ? 'transcript_summary' : 'metadata_description',
|
||||
transcript_summary: transcriptSummary,
|
||||
refined_at: new Date().toISOString(),
|
||||
captured_at: capturedAt,
|
||||
refined_at: capturedAt,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user