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
@@ -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') {