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:
“BeeRad”
2026-03-19 21:15:30 +11:00
parent 64aaf10feb
commit 1f8f41b4e0
14 changed files with 558 additions and 140 deletions
+129 -48
View File
@@ -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' }
+32 -6
View File
@@ -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 -1
View File
@@ -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;
+9
View File
@@ -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 {