sync: ingestion improvements from private repo

- Fixed dimension auto-assignment (locked dimensions now properly assigned)
- Added description field as grounding context for AI
- Updated embedding format: Title → Description → Content → Dimensions
- Description-weighted search (5x boost)
- Bug fixes: extraction tool dimension display, DimensionSearchModal closing
- Removed quickLink tool (use Quick Link workflow instead)
- Added regenerate-description API endpoint
This commit is contained in:
“BeeRad”
2026-01-13 11:03:56 +11:00
parent 3f0426ecf4
commit 4f030c7d29
24 changed files with 590 additions and 270 deletions
+19 -11
View File
@@ -13,6 +13,7 @@ export type QuickAddInputType = 'youtube' | 'website' | 'pdf' | 'note' | 'chat';
export interface QuickAddInput {
rawInput: string;
mode?: QuickAddMode;
description?: string;
}
function isLikelyChatTranscript(raw: string): boolean {
@@ -197,24 +198,31 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
});
}
async function handleNoteQuickAdd(rawInput: string, task: string): Promise<string> {
async function handleNoteQuickAdd(rawInput: string, task: string, userDescription?: string): Promise<string> {
const content = rawInput.trim();
if (!content) {
throw new Error('Input is required to create a note');
}
const title = deriveNoteTitle(content);
const nodePayload: Record<string, unknown> = {
title,
content,
metadata: {
source: 'quick-add-note',
refined_at: new Date().toISOString(),
},
};
// If user provided a description, use it instead of auto-generating
if (userDescription && userDescription.trim()) {
nodePayload.description = userDescription.trim();
}
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,
content,
metadata: {
source: 'quick-add-note',
refined_at: new Date().toISOString(),
},
}),
body: JSON.stringify(nodePayload),
});
const rawResult = await response.json();
@@ -332,7 +340,7 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
});
}
export async function enqueueQuickAdd({ rawInput, mode }: QuickAddInput) {
export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddInput) {
const inputType = detectInputType(rawInput, mode);
const context: string[] = (inputType === 'note' || inputType === 'chat') ? [] : [rawInput];
const task = buildTaskPrompt(inputType, rawInput);
@@ -350,7 +358,7 @@ export async function enqueueQuickAdd({ rawInput, mode }: QuickAddInput) {
let summary: string;
if (inputType === 'note') {
summary = await handleNoteQuickAdd(rawInput, task);
summary = await handleNoteQuickAdd(rawInput, task, description);
} else if (inputType === 'chat') {
summary = await handleChatTranscriptQuickAdd(rawInput, task);
} else {
@@ -0,0 +1,88 @@
import { openai as openaiProvider } from '@ai-sdk/openai';
import { generateText } from 'ai';
export interface DescriptionInput {
title: string;
content?: string;
metadata?: {
source?: string;
channel_name?: string;
author?: string;
site_name?: string;
};
type?: string;
}
/**
* Generate a 280-character description for a knowledge node.
* The description starts with "This is a..." and identifies the content type.
*/
export async function generateDescription(input: DescriptionInput): Promise<string> {
try {
const prompt = buildDescriptionPrompt(input);
console.log(`[DescriptionService] Generating description for: "${input.title}"`);
const response = await generateText({
model: openaiProvider('gpt-4o-mini'),
prompt,
maxOutputTokens: 100,
temperature: 0.3,
});
const description = response.text.trim();
// Ensure it starts with "This is a" and is within limit
const finalDescription = description.slice(0, 280);
console.log(`[DescriptionService] Generated: "${finalDescription}"`);
return finalDescription;
} catch (error) {
console.error('[DescriptionService] Error generating description:', error);
// Return a fallback description
return `This is a ${input.type || 'knowledge item'} titled "${input.title.slice(0, 200)}".`;
}
}
function buildDescriptionPrompt(input: DescriptionInput): string {
const metadataLines: string[] = [];
if (input.metadata?.source) {
metadataLines.push(`Source: ${input.metadata.source}`);
}
if (input.metadata?.channel_name) {
metadataLines.push(`Channel: ${input.metadata.channel_name}`);
}
if (input.metadata?.author) {
metadataLines.push(`Author: ${input.metadata.author}`);
}
if (input.metadata?.site_name) {
metadataLines.push(`Site: ${input.metadata.site_name}`);
}
if (input.type) {
metadataLines.push(`Type: ${input.type}`);
}
const contentPreview = input.content?.slice(0, 500) || '';
return `Generate a concise description (max 280 characters) for this knowledge item.
CRITICAL REQUIREMENTS:
- Start with "This is a..."
- Identify the content type (article, video, paper, podcast episode, tweet, book, tutorial, etc.)
- Be specific about what the content covers
- Maximum 280 characters total
=== KNOWLEDGE ITEM ===
Title: ${input.title}
${metadataLines.length > 0 ? metadataLines.join('\n') : ''}
${contentPreview ? `\nContent preview: ${contentPreview}${input.content && input.content.length > 500 ? '...' : ''}` : ''}
=== YOUR RESPONSE ===
Write ONLY the description, nothing else. Start with "This is a..."`;
}
export const descriptionService = {
generateDescription
};
+22 -14
View File
@@ -53,6 +53,7 @@ export class DimensionService {
title: string;
content?: string;
link?: string;
description?: string;
}): Promise<{ locked: string[]; keywords: string[] }> {
try {
const lockedDimensions = await this.getLockedDimensions();
@@ -69,7 +70,7 @@ export class DimensionService {
const response = await generateText({
model: openaiProvider('gpt-4o-mini'),
prompt,
maxOutputTokens: 150, // Increased for two-part response
maxOutputTokens: 300, // Increased to accommodate more dimensions
temperature: 0.1,
});
@@ -145,16 +146,29 @@ export class DimensionService {
* Build AI prompt for dimension assignment (locked + keyword dimensions)
*/
private static buildAssignmentPrompt(
nodeData: { title: string; content?: string; link?: string },
nodeData: { title: string; content?: string; link?: string; description?: string },
lockedDimensions: LockedDimension[]
): string {
const contentPreview = nodeData.content?.slice(0, 1000) || '';
// Use description as primary context, content as fallback
let nodeContextSection: string;
if (nodeData.description) {
const contentPreview = nodeData.content?.slice(0, 500) || '';
nodeContextSection = `DESCRIPTION: ${nodeData.description}
// Only include dimensions that have descriptions
const dimensionsWithDescriptions = lockedDimensions.filter(d => d.description && d.description.trim().length > 0);
CONTENT PREVIEW: ${contentPreview}${nodeData.content && nodeData.content.length > 500 ? '...' : ''}`;
} else {
const contentPreview = nodeData.content?.slice(0, 2000) || '';
nodeContextSection = `CONTENT: ${contentPreview}${nodeData.content && nodeData.content.length > 2000 ? '...' : ''}`;
}
const dimensionsList = dimensionsWithDescriptions
.map(d => `DIMENSION: "${d.name}"\nDESCRIPTION: ${d.description}`)
// Include ALL locked dimensions, using fallback text for those without descriptions
const dimensionsList = lockedDimensions
.map(d => {
const description = d.description && d.description.trim().length > 0
? d.description
: '(none - infer from name)';
return `DIMENSION: "${d.name}"\nDESCRIPTION: ${description}`;
})
.join('\n---\n');
return `You are categorizing a knowledge node. You will:
@@ -163,7 +177,7 @@ export class DimensionService {
=== NODE TO CATEGORIZE ===
Title: ${nodeData.title}
Content: ${contentPreview}${nodeData.content && nodeData.content.length > 1000 ? '...' : ''}
${nodeContextSection}
URL: ${nodeData.link || 'none'}
=== PART 1: LOCKED DIMENSIONS ===
@@ -171,7 +185,6 @@ CRITICAL: Read each dimension's DESCRIPTION carefully.
The description defines what belongs in that dimension.
Only assign if the content CLEARLY matches the description.
If unsure, skip it — better to miss than assign incorrectly.
Maximum 3 locked dimensions.
AVAILABLE LOCKED DIMENSIONS:
${dimensionsList}
@@ -224,11 +237,6 @@ KEYWORDS:
if (matchedDimension && !lockedDimensions.includes(matchedDimension.name)) {
lockedDimensions.push(matchedDimension.name);
// Limit to 3 locked dimensions
if (lockedDimensions.length >= 3) {
break;
}
}
}
}
+11 -9
View File
@@ -36,25 +36,27 @@ export class NodeService {
params.push(...dimensions);
}
// Text search in title and content (SQLite LIKE with COLLATE NOCASE)
// Text search in title, description, and content (SQLite LIKE with COLLATE NOCASE)
if (search) {
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.content LIKE ? COLLATE NOCASE)`;
params.push(`%${search}%`, `%${search}%`);
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.content LIKE ? COLLATE NOCASE)`;
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
}
// Sorting logic
if (search) {
// For search queries, prioritize by relevance: exact title → starts with → contains in title → content
query += ` ORDER BY
CASE WHEN LOWER(n.title) = LOWER(?) THEN 1 ELSE 5 END,
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 5 END,
CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 5 END,
CASE WHEN n.content LIKE ? COLLATE NOCASE THEN 4 ELSE 5 END,
// For search queries, prioritize by relevance: exact title → starts with → contains in title → description → content
query += ` ORDER BY
CASE WHEN LOWER(n.title) = LOWER(?) THEN 1 ELSE 6 END,
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 6 END,
CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 6 END,
CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 4 ELSE 6 END,
CASE WHEN n.content LIKE ? COLLATE NOCASE THEN 5 ELSE 6 END,
n.updated_at DESC`;
params.push(
search, // Exact match (case-insensitive)
`${search}%`, // Starts with search term
`%${search}%`, // Contains in title
`%${search}%`, // Contains in description
`%${search}%` // Contains in content
);
} else if (sortBy === 'edges') {
+6 -4
View File
@@ -17,6 +17,7 @@ interface NodeRecord {
id: number;
title: string;
content: string | null;
description: string | null;
dimensions_json: string;
embedding?: Buffer | null;
embedding_updated_at?: string | null;
@@ -106,7 +107,8 @@ Focus on the main concepts, key relationships, and practical implications.`;
let embeddingText = formatEmbeddingText(
node.title,
node.content || '',
dimensions
dimensions,
node.description
);
// Add AI analysis if content exists
@@ -175,7 +177,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
if (nodeId) {
// Single node
query = `
SELECT n.id, n.title, n.content,
SELECT n.id, n.title, n.content, n.description,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
n.embedding, n.embedding_updated_at
@@ -186,7 +188,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
} else if (forceReEmbed) {
// All nodes
query = `
SELECT n.id, n.title, n.content,
SELECT n.id, n.title, n.content, n.description,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
n.embedding, n.embedding_updated_at
@@ -196,7 +198,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
} else {
// Only nodes without embeddings
query = `
SELECT n.id, n.title, n.content,
SELECT n.id, n.title, n.content, n.description,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
n.embedding, n.embedding_updated_at
+4 -2
View File
@@ -83,10 +83,12 @@ export function createDatabaseConnection(): Database.Database {
export function formatEmbeddingText(
title: string,
content: string,
dimensions: string[]
dimensions: string[],
description?: string | null
): string {
const descriptionText = description && description.trim() ? description.trim() : 'none';
const dimensionsText = dimensions.length > 0 ? dimensions.join(', ') : 'none';
return `Title: ${title}\n\nContent: ${content}\n\nDimensions: ${dimensionsText}`;
return `Title: ${title}\n\nDescription: ${descriptionText}\n\nContent: ${content}\n\nDimensions: ${dimensionsText}`;
}
/**