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
+160 -42
View File
@@ -1,11 +1,11 @@
"use client";
import { useState, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
interface QuickAddSubmitPayload {
input: string;
mode: 'link' | 'note' | 'chat';
mode: 'link' | 'text';
description?: string;
}
@@ -15,34 +15,28 @@ interface QuickAddInputProps {
onClose?: () => void;
}
type DetectedType = 'youtube' | 'website' | 'pdf-url' | 'note' | 'chat';
type SourceType = 'link' | 'text' | 'upload';
function detectType(raw: string): DetectedType {
function detectSourceType(raw: string): Exclude<SourceType, 'upload'> {
const trimmed = raw.trim();
if (!trimmed) return 'note';
if (/youtu(\.be|be\.com)/i.test(trimmed)) return 'youtube';
if (/\.pdf($|\?)/i.test(trimmed) || /arxiv\.org\//i.test(trimmed)) return 'pdf-url';
if (/^https?:\/\//i.test(trimmed)) return 'website';
const newlines = (trimmed.match(/\n/g)?.length ?? 0);
if (newlines >= 3 && trimmed.length > 300) return 'chat';
if (/You said:|ChatGPT said:|Claude said:/i.test(trimmed)) return 'chat';
return 'note';
if (!trimmed) return 'text';
const isSingleLine = !trimmed.includes('\n');
if (isSingleLine) {
if (/^https?:\/\//i.test(trimmed)) return 'link';
}
return 'text';
}
const TYPE_LABELS: Record<DetectedType, string> = {
youtube: 'YouTube',
website: 'Link',
'pdf-url': 'PDF',
note: 'Note',
chat: 'Transcript',
const SOURCE_LABELS: Record<SourceType, string> = {
link: 'Link',
text: 'Text',
upload: 'Upload',
};
const TYPE_COLORS: Record<DetectedType, string> = {
youtube: '#ef4444',
website: '#3b82f6',
'pdf-url': '#f59e0b',
note: '#22c55e',
chat: '#a78bfa',
const SOURCE_COLORS: Record<SourceType, string> = {
link: '#3b82f6',
text: '#22c55e',
upload: '#f59e0b',
};
export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInputProps) {
@@ -52,6 +46,8 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
const [dragOver, setDragOver] = useState(false);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const [sourceOverride, setSourceOverride] = useState<Exclude<SourceType, 'upload'> | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const isControlled = isOpen !== undefined;
const isExpanded = isControlled ? isOpen : isExpandedInternal;
@@ -59,8 +55,8 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
? (value: boolean) => { if (!value && onClose) onClose(); }
: setIsExpandedInternal;
const detectedType = useMemo(() => detectType(input), [input]);
const showTypePill = input.trim().length > 0 && !uploadedFile;
const detectedSource = useMemo(() => detectSourceType(input), [input]);
const effectiveSource: SourceType = uploadedFile ? 'upload' : (sourceOverride ?? detectedSource);
const handleFileUpload = useCallback(async (file: File) => {
setIsPosting(true);
@@ -78,6 +74,7 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
}
setUploadedFile(null);
setInput('');
setSourceOverride(null);
setIsExpanded(false);
} catch (error) {
console.error('[QuickAddInput] Upload error:', error);
@@ -95,8 +92,12 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
if (!input.trim() || isPosting) return;
setIsPosting(true);
try {
await onSubmit({ input: input.trim(), mode: 'link' });
await onSubmit({
input: input.trim(),
mode: effectiveSource === 'link' ? 'link' : 'text',
});
setInput('');
setSourceOverride(null);
setIsExpanded(false);
} catch (error) {
console.error('[QuickAddInput] Submit error:', error);
@@ -115,6 +116,7 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
setInput('');
setUploadedFile(null);
setUploadError(null);
setSourceOverride(null);
}
};
@@ -147,6 +149,7 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
}
setUploadedFile(file);
setInput('');
setSourceOverride(null);
}, []);
const hasContent = input.trim() || uploadedFile;
@@ -156,6 +159,37 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
setInput('');
setUploadedFile(null);
setUploadError(null);
setSourceOverride(null);
};
const handleSourceSelect = (source: Exclude<SourceType, 'upload'>) => {
setSourceOverride(source);
setUploadedFile(null);
setUploadError(null);
};
const handleUploadClick = () => {
setUploadError(null);
fileInputRef.current?.click();
};
const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
setUploadError('Only PDF files are supported');
event.target.value = '';
return;
}
if (file.size > 50 * 1024 * 1024) {
setUploadError(`File too large (${Math.round(file.size / 1024 / 1024)}MB). Max 50MB.`);
event.target.value = '';
return;
}
setUploadedFile(file);
setInput('');
setSourceOverride(null);
event.target.value = '';
};
if (!isExpanded) {
@@ -210,7 +244,14 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
onDrop={handleDrop}
onClick={(e) => e.stopPropagation()}
>
{/* File preview */}
<input
ref={fileInputRef}
type="file"
accept=".pdf,application/pdf"
style={{ display: 'none' }}
onChange={handleFileInputChange}
/>
{uploadedFile && (
<div className="qa-file-row">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="1.5">
@@ -231,12 +272,10 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
</div>
)}
{/* Error */}
{uploadError && (
<div className="qa-error">{uploadError}</div>
)}
{/* Input area */}
{!uploadedFile && (
<div className={`qa-input-area ${dragOver ? 'dragging' : ''}`}>
{dragOver && (
@@ -250,9 +289,18 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
)}
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onChange={(e) => {
const nextValue = e.target.value;
setInput(nextValue);
if (nextValue.trim()) {
setUploadedFile(null);
}
if (!nextValue.trim()) {
setSourceOverride(null);
}
}}
onKeyDown={handleKeyDown}
placeholder="Paste a URL, write a note, or drop a PDF..."
placeholder="Paste a link, write/paste text, or upload a PDF..."
disabled={isPosting}
autoFocus
className="qa-textarea"
@@ -261,18 +309,40 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
</div>
)}
{/* Footer */}
<div className="qa-footer">
<div className="qa-footer-left">
{showTypePill && (
<span className="qa-type-pill" style={{
color: TYPE_COLORS[detectedType],
borderColor: TYPE_COLORS[detectedType] + '30',
background: TYPE_COLORS[detectedType] + '0a',
}}>
{TYPE_LABELS[detectedType]}
</span>
)}
<div className="qa-source-row">
<button
type="button"
className={`qa-control-chip qa-source-chip ${effectiveSource === 'link' ? 'active' : ''}`}
onClick={() => handleSourceSelect('link')}
>
{SOURCE_LABELS.link}
</button>
<button
type="button"
className={`qa-control-chip qa-source-chip ${effectiveSource === 'text' ? 'active' : ''}`}
onClick={() => handleSourceSelect('text')}
>
{SOURCE_LABELS.text}
</button>
<button
type="button"
className={`qa-control-chip qa-source-chip ${effectiveSource === 'upload' ? 'active upload' : 'upload'}`}
onClick={handleUploadClick}
>
{SOURCE_LABELS.upload}
</button>
</div>
</div>
<div className="qa-footer-right">
<span className="qa-type-pill" style={{
color: SOURCE_COLORS[effectiveSource],
borderColor: SOURCE_COLORS[effectiveSource] + '30',
background: SOURCE_COLORS[effectiveSource] + '0a',
}}>
{effectiveSource === 'upload' ? 'Process as Upload' : `Process as ${SOURCE_LABELS[effectiveSource]}`}
</span>
<span className="qa-hint">
{uploadedFile
? 'Ready to upload'
@@ -413,13 +483,61 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
border-top: 1px solid rgba(255, 255, 255, 0.04);
}
.qa-source-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.qa-control-chip {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: #8a8a8a;
border-radius: 10px;
padding: 6px 12px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
cursor: pointer;
transition: all 0.15s ease;
}
.qa-control-chip:hover {
border-color: rgba(255, 255, 255, 0.16);
color: #cfcfcf;
}
.qa-source-chip.active {
color: #f5f5f5;
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.08);
}
.qa-source-chip.upload.active {
border-color: rgba(245, 158, 11, 0.35);
background: rgba(245, 158, 11, 0.12);
color: #fbbf24;
}
.qa-footer-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.qa-footer-right {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
}
.qa-type-pill {
display: inline-flex;
align-items: center;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
+7 -8
View File
@@ -570,7 +570,7 @@ export default function ThreePanelLayout() {
}, [slotA, slotB, handlePaneTypeClick]);
// Handle Quick Add submit (used by global Add Stuff modal)
const handleQuickAddSubmit = useCallback(async ({ input, mode, description }: { input: string; mode: 'link' | 'note' | 'chat'; description?: string }) => {
const handleQuickAddSubmit = useCallback(async ({ input, mode, description }: { input: string; mode: 'link' | 'text'; description?: string }) => {
try {
const response = await fetch('/api/quick-add', {
method: 'POST',
@@ -583,15 +583,14 @@ export default function ThreePanelLayout() {
throw new Error(data.error || 'Failed to submit Quick Add');
}
const result = await response.json();
const delegation = result.delegation;
const data = await response.json();
const result = data.result as { id: string; inputType: string } | undefined;
// Add pending placeholder
if (delegation?.id && delegation?.inputType) {
if (result?.id) {
setPendingNodes(prev => [{
id: delegation.id,
input: input.slice(0, 120),
inputType: delegation.inputType,
id: result.id,
input: input.trim(),
inputType: result.inputType || 'note',
submittedAt: Date.now(),
status: 'processing' as const,
}, ...prev]);
+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 {
+97 -10
View File
@@ -5,21 +5,101 @@ import { generateText } from 'ai';
import { extractWebsite } from '@/services/typescript/extractors/website';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
// AI-powered content analysis
async function analyzeContentWithAI(title: string, description: string, contentType: string) {
interface ExistingDimension {
name: string;
description: string | null;
}
function inferWebsiteContentType(url: string): 'website' | 'tweet' {
try {
const hostname = new URL(url).hostname.toLowerCase();
return hostname === 'x.com' || hostname.endsWith('.x.com') || hostname === 'twitter.com' || hostname.endsWith('.twitter.com')
? 'tweet'
: 'website';
} catch {
return 'website';
}
}
async function fetchExistingDimensions(): Promise<ExistingDimension[]> {
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions/popular`);
if (!response.ok) return [];
const result = await response.json();
if (!Array.isArray(result.data)) return [];
return result.data
.map((dimension: { dimension?: unknown; description?: unknown }) => ({
name: typeof dimension.dimension === 'string' ? dimension.dimension.trim() : '',
description: typeof dimension.description === 'string' ? dimension.description.trim() : null
}))
.filter((dimension: ExistingDimension) => dimension.name.length > 0);
} catch (error) {
console.warn('Website dimension fetch fallback (no dimension context):', error);
return [];
}
}
function selectExistingDimensions(
selected: unknown,
existingDimensions: ExistingDimension[],
max = 5
): string[] {
if (!Array.isArray(selected) || existingDimensions.length === 0) return [];
const byLowerName = new Map(existingDimensions.map((dimension) => [dimension.name.toLowerCase(), dimension.name]));
const normalized: string[] = [];
const seen = new Set<string>();
for (const value of selected) {
if (typeof value !== 'string') continue;
const matched = byLowerName.get(value.trim().toLowerCase());
if (!matched) continue;
const key = matched.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
normalized.push(matched);
if (normalized.length >= max) break;
}
return normalized;
}
// AI-powered content analysis
async function analyzeContentWithAI(
title: string,
description: string,
contentType: string,
existingDimensions: ExistingDimension[]
) {
try {
const availableDimensionsBlock = existingDimensions.length > 0
? existingDimensions
.map((dimension) => `- ${dimension.name}${dimension.description ? `: ${dimension.description}` : ''}`)
.join('\n')
: '- No existing dimensions available. Return an empty dimensions array.';
const prompt = `Analyze this ${contentType} content and provide classification.
Title: "${title}"
Description: "${description}"
CRITICAL — nodeDescription rules (max 280 chars):
1. Say WHAT this literally is: "Blog post by…", "Article from…", "Essay arguing…", "Tutorial on…", "Thread by…"
1. Say WHAT this literally is using explicit entity words only: "Blog post by…", "Article from…", "Essay arguing…", "Tutorial on…", "Thread by…", "Tweet by…", "Post by…"
2. Name the author/site if known from the metadata.
3. State the actual claim or thesis — don't paraphrase into vague abstractions.
4. End with why it's interesting or important — one concrete phrase.
5. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for". State things directly.
DIMENSION SELECTION (critical):
You must select 0-3 dimensions from the list below.
Do NOT invent new dimension names.
Pick only dimensions that genuinely fit this content.
If nothing fits, return an empty array.
Available dimensions:
${availableDimensionsBlock}
Examples:
- Title: "Software is eating the world — again" / Author: Andrej Karpathy
GOOD: "Karpathy's blog post arguing AI agents make software fluid — they can rip functionality from repos instead of taking dependencies. Signals the end of monolithic libraries."
@@ -33,7 +113,7 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
{
"enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars). Cover key points, arguments, takeaways.",
"nodeDescription": "<your 280-char description following the rules above>",
"tags": ["relevant", "semantic", "tags"],
"dimensions": ["existing-dimension-1", "existing-dimension-2"],
"reasoning": "Brief explanation of classification choices"
}`;
@@ -53,7 +133,7 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
return {
enhancedDescription: result.enhancedDescription || description,
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 280) : undefined,
tags: Array.isArray(result.tags) ? result.tags : [],
dimensions: selectExistingDimensions(result.dimensions, existingDimensions, 5),
reasoning: result.reasoning || 'AI analysis completed'
};
} catch (error) {
@@ -62,7 +142,7 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
return {
enhancedDescription: description,
nodeDescription: undefined,
tags: [],
dimensions: [],
reasoning: 'Fallback description used'
};
}
@@ -122,10 +202,13 @@ export const websiteExtractTool = tool({
console.log('🎯 Website extraction successful, analyzing with AI...');
// Step 2: AI Analysis for enhanced metadata
const existingDimensions = await fetchExistingDimensions();
const contentType = inferWebsiteContentType(url);
const aiAnalysis = await analyzeContentWithAI(
result.metadata?.title || `Website: ${new URL(url).hostname}`,
result.notes?.substring(0, 2000) || 'Website content',
'website'
contentType,
existingDimensions
);
// Step 3: Create node with extracted content and AI analysis
@@ -138,6 +221,9 @@ export const websiteExtractTool = tool({
.filter(Boolean);
trimmedDimensions = trimmedDimensions.slice(0, 5);
const finalDimensions = trimmedDimensions.length > 0
? trimmedDimensions
: (aiAnalysis?.dimensions || []).slice(0, 5);
const createResponse = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
method: 'POST',
@@ -147,10 +233,11 @@ export const websiteExtractTool = tool({
description: aiAnalysis?.nodeDescription,
notes: enhancedDescription,
link: url,
dimensions: trimmedDimensions,
event_date: result.metadata?.published_date || result.metadata?.date || null,
dimensions: finalDimensions,
chunk: result.chunk || result.notes,
metadata: {
source: 'website',
source: contentType,
hostname: new URL(url).hostname,
author: result.metadata?.author,
published_date: result.metadata?.published_date || result.metadata?.date,
@@ -176,7 +263,7 @@ export const websiteExtractTool = tool({
console.log('🎯 WebsiteExtract completed successfully');
// Use actual assigned dimensions from API response (includes auto-assigned locked + keywords)
const actualDimensions: string[] = createResult.data?.dimensions || trimmedDimensions || [];
const actualDimensions: string[] = createResult.data?.dimensions || finalDimensions || [];
const formattedNode = createResult.data?.id
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
: nodeTitle;
+82 -8
View File
@@ -5,9 +5,69 @@ import { generateText } from 'ai';
import { extractYouTube } from '@/services/typescript/extractors/youtube';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
// AI-powered content analysis
async function analyzeContentWithAI(title: string, description: string, contentType: string) {
interface ExistingDimension {
name: string;
description: string | null;
}
async function fetchExistingDimensions(): Promise<ExistingDimension[]> {
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions/popular`);
if (!response.ok) return [];
const result = await response.json();
if (!Array.isArray(result.data)) return [];
return result.data
.map((dimension: { dimension?: unknown; description?: unknown }) => ({
name: typeof dimension.dimension === 'string' ? dimension.dimension.trim() : '',
description: typeof dimension.description === 'string' ? dimension.description.trim() : null
}))
.filter((dimension: ExistingDimension) => dimension.name.length > 0);
} catch (error) {
console.warn('YouTube dimension fetch fallback (no dimension context):', error);
return [];
}
}
function selectExistingDimensions(
selected: unknown,
existingDimensions: ExistingDimension[],
max = 5
): string[] {
if (!Array.isArray(selected) || existingDimensions.length === 0) return [];
const byLowerName = new Map(existingDimensions.map((dimension) => [dimension.name.toLowerCase(), dimension.name]));
const normalized: string[] = [];
const seen = new Set<string>();
for (const value of selected) {
if (typeof value !== 'string') continue;
const matched = byLowerName.get(value.trim().toLowerCase());
if (!matched) continue;
const key = matched.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
normalized.push(matched);
if (normalized.length >= max) break;
}
return normalized;
}
// AI-powered content analysis
async function analyzeContentWithAI(
title: string,
description: string,
contentType: string,
existingDimensions: ExistingDimension[]
) {
try {
const availableDimensionsBlock = existingDimensions.length > 0
? existingDimensions
.map((dimension) => `- ${dimension.name}${dimension.description ? `: ${dimension.description}` : ''}`)
.join('\n')
: '- No existing dimensions available. Return an empty dimensions array.';
const prompt = `Analyze this ${contentType} content and provide classification.
Title: "${title}"
@@ -20,6 +80,15 @@ CRITICAL — nodeDescription rules (max 280 chars):
4. End with why it's interesting or important — one concrete phrase.
5. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for". State things directly.
DIMENSION SELECTION (critical):
You must select 0-3 dimensions from the list below.
Do NOT invent new dimension names.
Pick only dimensions that genuinely fit this content.
If nothing fits, return an empty array.
Available dimensions:
${availableDimensionsBlock}
Examples:
- Title: "Dario Amodei — We are near the end of the exponential" / Channel: Dwarkesh Patel
GOOD: "Dwarkesh Patel interview with Anthropic CEO Dario Amodei — argues we're nearing the end of exponential AI scaling. Key signal for what the next phase of AI development looks like."
@@ -33,7 +102,7 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
{
"enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars). Cover key points, arguments, takeaways.",
"nodeDescription": "<your 280-char description following the rules above>",
"tags": ["relevant", "semantic", "tags"],
"dimensions": ["existing-dimension-1", "existing-dimension-2"],
"reasoning": "Brief explanation of classification choices"
}`;
@@ -53,7 +122,7 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
return {
enhancedDescription: result.enhancedDescription || description,
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 280) : undefined,
tags: Array.isArray(result.tags) ? result.tags : [],
dimensions: selectExistingDimensions(result.dimensions, existingDimensions, 5),
reasoning: result.reasoning || 'AI analysis completed'
};
} catch (error) {
@@ -62,7 +131,7 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
return {
enhancedDescription: description,
nodeDescription: undefined,
tags: [],
dimensions: [],
reasoning: 'Fallback description used'
};
}
@@ -164,10 +233,12 @@ export const youtubeExtractTool = tool({
console.log('🎯 YouTube extraction successful, analyzing with AI...');
// Step 2: AI Analysis for enhanced metadata
const existingDimensions = await fetchExistingDimensions();
const aiAnalysis = await analyzeContentWithAI(
result.metadata?.video_title || 'YouTube Video',
`Video by ${result.metadata?.channel_name || 'Unknown Channel'}`,
'youtube'
'youtube',
existingDimensions
);
// Step 3: Create node with extracted content and AI analysis
@@ -181,6 +252,9 @@ export const youtubeExtractTool = tool({
.filter(Boolean);
trimmedDimensions = trimmedDimensions.slice(0, 5);
const finalDimensions = trimmedDimensions.length > 0
? trimmedDimensions
: (aiAnalysis?.dimensions || []).slice(0, 5);
const createResponse = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
method: 'POST',
@@ -190,7 +264,7 @@ export const youtubeExtractTool = tool({
description: aiAnalysis?.nodeDescription,
notes: nodeNotes,
link: url,
dimensions: trimmedDimensions,
dimensions: finalDimensions,
chunk: result.chunk || result.notes,
metadata: {
source: 'youtube',
@@ -222,7 +296,7 @@ export const youtubeExtractTool = tool({
console.log('🎯 YouTubeExtract completed successfully');
// Use actual assigned dimensions from API response (includes auto-assigned locked + keywords)
const actualDimensions: string[] = createResult.data?.dimensions || trimmedDimensions || [];
const actualDimensions: string[] = createResult.data?.dimensions || finalDimensions || [];
const formattedNode = createResult.data?.id
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
: nodeTitle;