feat: sync quick add ingestion updates
- simplify quick add source selection and fallback saving - improve description context and manual edit behavior - add extractor dimension filtering and edge explanation migration
This commit is contained in:
+129
-48
@@ -6,7 +6,7 @@ import { formatNodeForChat } from '@/tools/infrastructure/nodeFormatter';
|
||||
import { summarizeTranscript } from './transcriptSummarizer';
|
||||
import { eventBroadcaster } from '@/services/events';
|
||||
|
||||
export type QuickAddMode = 'link' | 'note' | 'chat';
|
||||
export type QuickAddMode = 'link' | 'text';
|
||||
|
||||
export type QuickAddInputType = 'youtube' | 'website' | 'pdf' | 'note' | 'chat';
|
||||
|
||||
@@ -16,6 +16,15 @@ export interface QuickAddInput {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface QuickAddResult {
|
||||
id: string;
|
||||
task: string;
|
||||
inputType: QuickAddInputType;
|
||||
status: 'queued' | 'completed' | 'failed';
|
||||
summary?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function isLikelyChatTranscript(raw: string): boolean {
|
||||
const newlineCount = (raw.match(/\n/g)?.length ?? 0);
|
||||
if (newlineCount >= 3 && raw.length > 300) return true;
|
||||
@@ -25,13 +34,23 @@ function isLikelyChatTranscript(raw: string): boolean {
|
||||
}
|
||||
|
||||
export function detectInputType(raw: string, mode?: QuickAddMode): QuickAddInputType {
|
||||
if (mode === 'chat') return 'chat';
|
||||
if (mode === 'note') return 'note';
|
||||
|
||||
const input = raw.trim();
|
||||
if (/youtu(\.be|be\.com)/i.test(input)) return 'youtube';
|
||||
if (/\.pdf($|\?)/i.test(input) || /arxiv\.org\//i.test(input)) return 'pdf';
|
||||
if (/^https?:\/\//i.test(input)) return 'website';
|
||||
const isSingleLine = !input.includes('\n');
|
||||
|
||||
if (mode === 'text') {
|
||||
return isLikelyChatTranscript(input) ? 'chat' : 'note';
|
||||
}
|
||||
|
||||
if (isSingleLine) {
|
||||
if (/youtu(\.be|be\.com)/i.test(input)) return 'youtube';
|
||||
if (/\.pdf($|\?)/i.test(input) || /arxiv\.org\//i.test(input)) return 'pdf';
|
||||
if (/^https?:\/\//i.test(input)) return 'website';
|
||||
}
|
||||
|
||||
if (mode === 'link') {
|
||||
return 'website';
|
||||
}
|
||||
|
||||
if (!mode && isLikelyChatTranscript(input)) return 'chat';
|
||||
return 'note';
|
||||
}
|
||||
@@ -84,6 +103,30 @@ interface CreateNodeResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function deriveFallbackLinkTitle(rawUrl: string): string {
|
||||
const trimmed = rawUrl.trim();
|
||||
if (!trimmed) return 'Saved link';
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const hostname = parsed.hostname.replace(/^www\./, '');
|
||||
const lastSegment = parsed.pathname
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.pop()
|
||||
?.replace(/[-_]+/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (lastSegment) {
|
||||
return `${hostname}: ${lastSegment}`.slice(0, 160);
|
||||
}
|
||||
|
||||
return hostname.slice(0, 160);
|
||||
} catch {
|
||||
return trimmed.slice(0, 160);
|
||||
}
|
||||
}
|
||||
|
||||
function buildStructuredSummary({ task, action, resultMessage, nodeReference }: SummaryParts): string {
|
||||
const normalizedResult = resultMessage?.trim().length ? resultMessage.trim() : `${action} completed.`;
|
||||
const normalizedNode = nodeReference || 'None';
|
||||
@@ -160,32 +203,84 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
|
||||
if (!execute) {
|
||||
throw new Error(`Tool ${toolName} does not have an execute function`);
|
||||
}
|
||||
const rawResult = await execute({ url }, { toolCallId: 'quickadd-extract', messages: [] });
|
||||
|
||||
if (!isExtractionToolResult(rawResult)) {
|
||||
throw new Error(`Unexpected response from ${toolName}`);
|
||||
try {
|
||||
const rawResult = await execute({ url }, { toolCallId: 'quickadd-extract', messages: [] });
|
||||
|
||||
if (!isExtractionToolResult(rawResult)) {
|
||||
throw new Error(`Unexpected response from ${toolName}`);
|
||||
}
|
||||
|
||||
const toolResult = rawResult;
|
||||
|
||||
if (!toolResult || toolResult.success === false) {
|
||||
const errorMessage = toolResult?.error || `Failed to execute ${toolName}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const summaryLine = summarizeToolExecution(toolName, { url }, toolResult);
|
||||
const nodeId = toolResult.data?.nodeId;
|
||||
const nodeTitle = typeof toolResult.data?.title === 'string' && toolResult.data.title.trim().length > 0
|
||||
? toolResult.data.title.trim()
|
||||
: nodeId ? `Node ${nodeId}` : 'Created node';
|
||||
const nodeReference = nodeId ? formatNodeForChat({ id: nodeId, title: nodeTitle }) : 'None';
|
||||
|
||||
return buildStructuredSummary({
|
||||
task,
|
||||
action: toolName,
|
||||
resultMessage: summaryLine,
|
||||
nodeReference,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : `Failed to execute ${toolName}`;
|
||||
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.`;
|
||||
const notes = [
|
||||
`Original URL: ${url}`,
|
||||
`Ingestion failure: ${message}`,
|
||||
`Attempted pipeline: ${type}`,
|
||||
].join('\n');
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
notes,
|
||||
link: url,
|
||||
metadata: {
|
||||
source: 'quick-add-link-fallback',
|
||||
attempted_pipeline: type,
|
||||
extraction_failed: true,
|
||||
extraction_error: message,
|
||||
refined_at: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const rawFallbackResult = await response.json();
|
||||
if (!isCreateNodeResponse(rawFallbackResult)) {
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(rawFallbackResult.error || message);
|
||||
}
|
||||
|
||||
const nodeId = rawFallbackResult.data?.id;
|
||||
const nodeReference = nodeId ? formatNodeForChat({ id: nodeId, title }) : 'None';
|
||||
const resultMessage = nodeId
|
||||
? `Link ingestion failed, so RA-H saved a fallback node ${nodeReference}. Reason: ${message}`
|
||||
: `Link ingestion failed, so RA-H saved a fallback node. Reason: ${message}`;
|
||||
|
||||
return buildStructuredSummary({
|
||||
task,
|
||||
action: `${toolName} (fallback)`,
|
||||
resultMessage,
|
||||
nodeReference,
|
||||
});
|
||||
}
|
||||
|
||||
const toolResult = rawResult;
|
||||
|
||||
if (!toolResult || toolResult.success === false) {
|
||||
const errorMessage = toolResult?.error || `Failed to execute ${toolName}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const summaryLine = summarizeToolExecution(toolName, { url }, toolResult);
|
||||
const nodeId = toolResult.data?.nodeId;
|
||||
const nodeTitle = typeof toolResult.data?.title === 'string' && toolResult.data.title.trim().length > 0
|
||||
? toolResult.data.title.trim()
|
||||
: nodeId ? `Node ${nodeId}` : 'Created node';
|
||||
const nodeReference = nodeId ? formatNodeForChat({ id: nodeId, title: nodeTitle }) : 'None';
|
||||
|
||||
return buildStructuredSummary({
|
||||
task,
|
||||
action: toolName,
|
||||
resultMessage: summaryLine,
|
||||
nodeReference,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleNoteQuickAdd(rawInput: string, task: string, userDescription?: string): Promise<string> {
|
||||
@@ -204,7 +299,6 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
|
||||
},
|
||||
};
|
||||
|
||||
// If user provided a description, use it instead of auto-generating
|
||||
if (userDescription && userDescription.trim()) {
|
||||
nodePayload.description = userDescription.trim();
|
||||
}
|
||||
@@ -330,15 +424,6 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
|
||||
});
|
||||
}
|
||||
|
||||
export interface QuickAddResult {
|
||||
id: string;
|
||||
task: string;
|
||||
inputType: QuickAddInputType;
|
||||
status: 'queued' | 'completed' | 'failed';
|
||||
summary?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddInput): Promise<QuickAddResult> {
|
||||
const inputType = detectInputType(rawInput, mode);
|
||||
const task = buildTaskPrompt(inputType, rawInput);
|
||||
@@ -351,25 +436,21 @@ export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddI
|
||||
status: 'queued',
|
||||
};
|
||||
|
||||
// Run async - fire and forget
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
let summary: string;
|
||||
if (inputType === 'note') {
|
||||
summary = await handleNoteQuickAdd(rawInput, task, description);
|
||||
await handleNoteQuickAdd(rawInput, task, description);
|
||||
} else if (inputType === 'chat') {
|
||||
summary = await handleChatTranscriptQuickAdd(rawInput, task);
|
||||
await handleChatTranscriptQuickAdd(rawInput, task);
|
||||
} else {
|
||||
summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task);
|
||||
await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task);
|
||||
}
|
||||
|
||||
console.log(`[QuickAdd] Completed: ${task}`);
|
||||
// Broadcast completion so ThreePanelLayout can remove the pending placeholder
|
||||
eventBroadcaster.broadcast({
|
||||
type: 'QUICK_ADD_COMPLETED',
|
||||
data: { quickAddId: id, source: 'quick-add' }
|
||||
});
|
||||
// Also broadcast NODE_CREATED to refresh the feed
|
||||
eventBroadcaster.broadcast({
|
||||
type: 'NODE_CREATED',
|
||||
data: { node: { title: task }, source: 'quick-add' }
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface DescriptionInput {
|
||||
channel_name?: string;
|
||||
author?: string;
|
||||
site_name?: string;
|
||||
original_filename?: string;
|
||||
pages?: number;
|
||||
text_length?: number;
|
||||
};
|
||||
dimensions?: string[];
|
||||
}
|
||||
@@ -75,10 +78,7 @@ export async function generateDescription(input: DescriptionInput): Promise<stri
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
const description = response.text.trim();
|
||||
|
||||
// Ensure within character limit
|
||||
const finalDescription = description.slice(0, 280);
|
||||
const finalDescription = sanitizeDescription(response.text, input);
|
||||
|
||||
console.log(`[DescriptionService] Generated: "${finalDescription}"`);
|
||||
|
||||
@@ -126,6 +126,10 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
|
||||
if (input.metadata?.channel_name) lines.push(`Channel: ${input.metadata.channel_name}`);
|
||||
if (input.metadata?.author) lines.push(`Author: ${input.metadata.author}`);
|
||||
if (input.metadata?.site_name) lines.push(`Site: ${input.metadata.site_name}`);
|
||||
if (input.metadata?.source) lines.push(`Source type: ${input.metadata.source}`);
|
||||
if (input.metadata?.original_filename) lines.push(`Original filename: ${input.metadata.original_filename}`);
|
||||
if (typeof input.metadata?.pages === 'number') lines.push(`Pages: ${input.metadata.pages}`);
|
||||
if (typeof input.metadata?.text_length === 'number') lines.push(`Text length: ${input.metadata.text_length}`);
|
||||
if (creatorHint) lines.push(`Creator hint: ${creatorHint}`);
|
||||
if (publisherHint) lines.push(`Publisher hint: ${publisherHint}`);
|
||||
lines.push(`Likely user-authored: ${likelyUserAuthored ? 'yes' : 'no'}`);
|
||||
@@ -138,15 +142,19 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
|
||||
Say WHAT this literally is and WHY it matters. Be concrete and specific — like you're telling a friend what this thing is in one breath.
|
||||
|
||||
RULES:
|
||||
1) Name the format: "Podcast episode where…", "Blog post arguing…", "Your note on…", "Research paper showing…", "Idea that…"
|
||||
1) Name the format only if the context clearly supports it: "Podcast episode where…", "Blog post arguing…", "Personal note capturing…", "Research paper showing…", "Resume/CV for…", "Document likely containing…", "Idea that…"
|
||||
2) Name people by role — channel/host is the creator, title figures are guests/subjects. Use the Creator hint if available.
|
||||
3) State the actual claim, finding, or insight from the content — not a vague summary of the topic.
|
||||
4) End with why it's interesting or important — one concrete phrase.
|
||||
5) ABSOLUTELY FORBIDDEN — these words will be rejected: "discusses", "explores", "examines", "talks about", "is about", "delves into", "emphasizing the need for". State things directly instead.
|
||||
6) Do NOT start with "Your note —" or "This note —". Use a concrete opener tied to the actual artifact.
|
||||
7) If the artifact type is unclear, say so explicitly using words like "likely", "appears to be", or "unclear" rather than guessing a confident format.
|
||||
|
||||
GOOD: "Karpathy blog post — AI agents make software fluid, ripping functionality from repos instead of taking dependencies. Signals the end of monolithic libraries."
|
||||
GOOD: "Dwarkesh Patel interview with Anthropic CEO Dario Amodei — argues we're nearing the end of exponential AI scaling. Key signal for what comes next."
|
||||
GOOD: "Your note — morning optimism consistently reverses to evening pessimism. Not energy — the belief itself flips. Pattern worth tracking."
|
||||
GOOD: "Personal note capturing a recurring pattern: morning optimism reverses to evening pessimism. Indicates a belief-level swing worth tracking."
|
||||
GOOD: "Resume/CV for Brad Morris outlining work in AI systems, context engineering, and RA-H. Useful as a compact record of background, projects, and expertise."
|
||||
GOOD: "Document likely related to Brad Morris's work history and AI consulting, but the exact artifact type is unclear from the available context. Still useful as a reference profile."
|
||||
BAD: "By Dario Amodei — discusses reaching the limits of exponential growth in AI, emphasizing the need for a critical perspective on future advancements."
|
||||
BAD: "This article explores ideas about how software is changing."
|
||||
|
||||
@@ -155,6 +163,24 @@ Return ONLY the description text. Nothing else.
|
||||
${lines.join('\n')}`;
|
||||
}
|
||||
|
||||
function sanitizeDescription(rawText: string, input: DescriptionInput): string {
|
||||
const singleLine = rawText
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^["']|["']$/g, '');
|
||||
|
||||
if (!singleLine) {
|
||||
return input.title.slice(0, 280);
|
||||
}
|
||||
|
||||
const noGenericPrefix = singleLine.replace(
|
||||
/^(your note|this note)\s*[—:-]\s*/i,
|
||||
'Personal note capturing '
|
||||
);
|
||||
|
||||
return noGenericPrefix.slice(0, 280);
|
||||
}
|
||||
|
||||
export const descriptionService = {
|
||||
generateDescription
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const WEAK_DESCRIPTION_PATTERNS = /\b(discusses|explores|examines|talks about|is about|delves into)\b/i;
|
||||
const EXPLICIT_ENTITY_PATTERNS = /\b(article|artifact|book|brief|claim|company|conversation|dataset|decision|dimension|document|episode|essay|guide|idea|insight|interview|node|note|paper|person|plan|placeholder|podcast|presentation|project|question|record|research|skill|source|summary|talk|target|test node|thread|tool|transcript|tweet|video|website|workflow)\b/i;
|
||||
const EXPLICIT_ENTITY_PATTERNS = /\b(article|artifact|book|brief|claim|company|concept|conversation|dataset|decision|dimension|document|episode|essay|event|guide|idea|insight|interview|lesson|link|node|note|paper|person|plan|placeholder|podcast|post|presentation|project|question|record|research|resource|skill|source|status|summary|talk|target|test node|thread|tool|transcript|tweet|update|video|website|workflow)\b/i;
|
||||
const UNCERTAINTY_PATTERNS = /\b(likely|probably|possibly|appears to be|seems to be|unclear|uncertain)\b/i;
|
||||
const GENERIC_EDGE_PATTERNS = /^(related|related to|connected|connected to|association|associated with)$/i;
|
||||
|
||||
|
||||
@@ -657,6 +657,15 @@ class SQLiteClient {
|
||||
if (edgeCols.some(c => c.name === 'user_feedback')) {
|
||||
try { this.db.exec('ALTER TABLE edges DROP COLUMN user_feedback;'); } catch {}
|
||||
}
|
||||
if (!edgeCols.some(c => c.name === 'explanation')) {
|
||||
this.db.exec('ALTER TABLE edges ADD COLUMN explanation TEXT;');
|
||||
try {
|
||||
this.db.exec(`
|
||||
UPDATE edges SET explanation = json_extract(context, '$.explanation')
|
||||
WHERE explanation IS NULL AND json_extract(context, '$.explanation') IS NOT NULL;
|
||||
`);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Recreate nodes_fts to index title + description + notes
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user