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:
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user