fix: use AI SDK + language detection for label suggestions
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 43s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 43s
Same pattern as TitleSuggestionService: getModel() + generateText with system/user prompts. LanguageDetectionService (tinyld) auto-detects note content language. Labels now match note language (Persian note → Persian labels). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import { prisma } from '@/lib/prisma'
|
|||||||
import { getTagsProvider } from '@/lib/ai/factory'
|
import { getTagsProvider } from '@/lib/ai/factory'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import { LanguageDetectionService } from './language-detection.service'
|
import { LanguageDetectionService } from './language-detection.service'
|
||||||
|
import { generateText } from 'ai'
|
||||||
|
|
||||||
export interface LabelSuggestion {
|
export interface LabelSuggestion {
|
||||||
label: string
|
label: string
|
||||||
@@ -84,84 +85,55 @@ export class ContextualAutoTagService {
|
|||||||
private async suggestFromExistingLabels(
|
private async suggestFromExistingLabels(
|
||||||
noteContent: string,
|
noteContent: string,
|
||||||
notebook: any,
|
notebook: any,
|
||||||
language: string
|
contentLanguage: string
|
||||||
): Promise<LabelSuggestion[]> {
|
): Promise<LabelSuggestion[]> {
|
||||||
const availableLabels = notebook.labels.map((l: any) => l.name)
|
const availableLabels = notebook.labels.map((l: any) => l.name)
|
||||||
|
const labelList = availableLabels.map((l: string) => `- ${l}`).join('\n')
|
||||||
// Build prompt with available labels
|
|
||||||
const prompt = this.buildPrompt(noteContent, notebook.name, availableLabels, language)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
const provider = getTagsProvider(config)
|
const provider = getTagsProvider(config)
|
||||||
|
const model = provider.getModel()
|
||||||
|
|
||||||
// Use generateText with JSON response
|
const systemPrompt = `You suggest labels for notes from an existing list.
|
||||||
const response = await provider.generateText(prompt)
|
Rules:
|
||||||
console.log('[ContextualAutoTag] AI raw response (existing):', response?.substring(0, 300))
|
- Only suggest a label if DIRECTLY related to THIS note's content
|
||||||
|
- Do NOT suggest labels just because they exist
|
||||||
|
- Maximum 2 suggestions
|
||||||
|
- Confidence < 0.7 = do not suggest
|
||||||
|
- If NO label is relevant, return: {"suggestions":[]}
|
||||||
|
Output JSON only: {"suggestions":[{"label":"name","confidence":0.85,"reasoning":"why"}]}`
|
||||||
|
|
||||||
// Improved JSON parsing with multiple fallback strategies
|
const userPrompt = `Note (language: ${contentLanguage}):
|
||||||
let parsed: any
|
${noteContent.substring(0, 1000)}
|
||||||
|
|
||||||
// Strategy 1: Direct parse
|
Available labels:
|
||||||
try {
|
${labelList}
|
||||||
parsed = JSON.parse(response)
|
|
||||||
} catch (e) {
|
|
||||||
// Strategy 2: Extract JSON from markdown code blocks
|
|
||||||
const codeBlockMatch = response.match(/```(?:json)?\s*(\{[\s\S]*?\}|\[[\s\S]*?\])\s*```/)
|
|
||||||
if (codeBlockMatch) {
|
|
||||||
parsed = JSON.parse(codeBlockMatch[1])
|
|
||||||
} else {
|
|
||||||
// Strategy 3: Extract JSON object or array
|
|
||||||
const jsonArrayMatch = response.match(/\[[\s\S]*\]/)
|
|
||||||
const jsonObjectMatch = response.match(/\{[\s\S]*\}/)
|
|
||||||
|
|
||||||
if (jsonArrayMatch) {
|
Suggest relevant labels from the list above.`
|
||||||
let cleanedJson = jsonArrayMatch[0]
|
|
||||||
cleanedJson = cleanedJson.replace(/,\s*([}\]])/g, '$1')
|
|
||||||
cleanedJson = cleanedJson.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":')
|
|
||||||
parsed = JSON.parse(cleanedJson)
|
|
||||||
} else if (jsonObjectMatch) {
|
|
||||||
let cleanedJson = jsonObjectMatch[0]
|
|
||||||
cleanedJson = cleanedJson.replace(/,\s*([}\]])/g, '$1')
|
|
||||||
cleanedJson = cleanedJson.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":')
|
|
||||||
parsed = JSON.parse(cleanedJson)
|
|
||||||
} else {
|
|
||||||
console.error('[ContextualAutoTag] Could not extract JSON from response')
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle both formats: array directly OR {suggestions: array}
|
const { text } = await generateText({ model, system: systemPrompt, prompt: userPrompt, temperature: 0.3 })
|
||||||
let suggestionsArray = parsed
|
console.log('[ContextualAutoTag] AI response (existing):', text?.substring(0, 300))
|
||||||
if (parsed.suggestions && Array.isArray(parsed.suggestions)) {
|
|
||||||
suggestionsArray = parsed.suggestions
|
const parsed = this.parseAIResponse(text)
|
||||||
} else if (Array.isArray(parsed)) {
|
if (!parsed) return []
|
||||||
suggestionsArray = parsed
|
|
||||||
} else {
|
|
||||||
console.error('[ContextualAutoTag] Invalid response structure:', parsed)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter and map suggestions (case-insensitive, strict threshold)
|
|
||||||
const lowerAvailable = availableLabels.map((l: string) => l.toLowerCase())
|
const lowerAvailable = availableLabels.map((l: string) => l.toLowerCase())
|
||||||
const suggestions = suggestionsArray
|
const suggestions = parsed
|
||||||
.filter((s: any) => {
|
.filter((s: any) => s.label && lowerAvailable.includes(s.label.toLowerCase()) && (s.confidence || 0) > 0.5)
|
||||||
return s.label && lowerAvailable.includes(s.label.toLowerCase()) && (s.confidence || 0) > 0.5
|
|
||||||
})
|
|
||||||
.map((s: any) => {
|
.map((s: any) => {
|
||||||
const originalLabel = availableLabels.find((l: string) => l.toLowerCase() === s.label.toLowerCase()) || s.label
|
const originalLabel = availableLabels.find((l: string) => l.toLowerCase() === s.label.toLowerCase()) || s.label
|
||||||
return {
|
return {
|
||||||
label: originalLabel,
|
label: originalLabel,
|
||||||
confidence: Math.round(s.confidence * 100),
|
confidence: Math.round(s.confidence * 100),
|
||||||
reasoning: s.reasoning || '',
|
reasoning: s.reasoning || '',
|
||||||
isNewLabel: false,
|
isNewLabel: false,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.sort((a: any, b: any) => b.confidence - a.confidence)
|
.sort((a: any, b: any) => b.confidence - a.confidence)
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
|
|
||||||
console.log('[ContextualAutoTag] filtered existing suggestions:', suggestions.length, suggestions.map((s: any) => `${s.label}(${s.confidence})`))
|
console.log('[ContextualAutoTag] filtered existing:', suggestions.map((s: any) => `${s.label}(${s.confidence})`))
|
||||||
return suggestions as LabelSuggestion[]
|
return suggestions as LabelSuggestion[]
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to suggest labels:', error)
|
console.error('Failed to suggest labels:', error)
|
||||||
@@ -175,134 +147,81 @@ export class ContextualAutoTagService {
|
|||||||
private async suggestNewLabels(
|
private async suggestNewLabels(
|
||||||
noteContent: string,
|
noteContent: string,
|
||||||
notebook: any,
|
notebook: any,
|
||||||
language: string
|
contentLanguage: string
|
||||||
): Promise<LabelSuggestion[]> {
|
): Promise<LabelSuggestion[]> {
|
||||||
// Build prompt to suggest NEW labels based on content
|
|
||||||
const prompt = this.buildNewLabelsPrompt(noteContent, notebook.name, language)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
const provider = getTagsProvider(config)
|
const provider = getTagsProvider(config)
|
||||||
|
const model = provider.getModel()
|
||||||
|
|
||||||
// Use generateText with JSON response
|
const systemPrompt = `You suggest labels to organize notes.
|
||||||
const response = await provider.generateText(prompt)
|
Rules:
|
||||||
console.log('[ContextualAutoTag] AI raw response (new):', response?.substring(0, 300))
|
- Suggest 1-3 short labels (max 1-2 words)
|
||||||
|
- Labels MUST be in the SAME LANGUAGE as the note content
|
||||||
|
- Labels must be lowercase
|
||||||
|
- Maximum 3 suggestions, confidence < 0.6 = do not suggest
|
||||||
|
Output JSON only: {"suggestions":[{"label":"name","confidence":0.85,"reasoning":"why"}]}`
|
||||||
|
|
||||||
// Improved JSON parsing with multiple fallback strategies
|
const userPrompt = `Note (language: ${contentLanguage}):
|
||||||
let parsed: any
|
${noteContent.substring(0, 1000)}
|
||||||
|
|
||||||
// Strategy 1: Direct parse
|
Notebook: ${notebook.name}
|
||||||
try {
|
|
||||||
parsed = JSON.parse(response)
|
|
||||||
} catch (e) {
|
|
||||||
// Strategy 2: Extract JSON from markdown code blocks
|
|
||||||
const codeBlockMatch = response.match(/```(?:json)?\s*(\{[\s\S]*?\}|\[[\s\S]*?\])\s*```/)
|
|
||||||
if (codeBlockMatch) {
|
|
||||||
parsed = JSON.parse(codeBlockMatch[1])
|
|
||||||
} else {
|
|
||||||
// Strategy 3: Extract JSON object or array
|
|
||||||
const jsonArrayMatch = response.match(/\[[\s\S]*\]/)
|
|
||||||
const jsonObjectMatch = response.match(/\{[\s\S]*\}/)
|
|
||||||
|
|
||||||
if (jsonArrayMatch) {
|
Note language detected: ${contentLanguage}
|
||||||
let cleanedJson = jsonArrayMatch[0]
|
Respond with labels in ${contentLanguage} (same language as the note).`
|
||||||
cleanedJson = cleanedJson.replace(/,\s*([}\]])/g, '$1')
|
|
||||||
cleanedJson = cleanedJson.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":')
|
|
||||||
parsed = JSON.parse(cleanedJson)
|
|
||||||
} else if (jsonObjectMatch) {
|
|
||||||
let cleanedJson = jsonObjectMatch[0]
|
|
||||||
cleanedJson = cleanedJson.replace(/,\s*([}\]])/g, '$1')
|
|
||||||
cleanedJson = cleanedJson.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":')
|
|
||||||
parsed = JSON.parse(cleanedJson)
|
|
||||||
} else {
|
|
||||||
console.error('[ContextualAutoTag] Could not extract JSON from new-labels response')
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle both formats: array directly OR {suggestions: array}
|
const { text } = await generateText({ model, system: systemPrompt, prompt: userPrompt, temperature: 0.3 })
|
||||||
let suggestionsArray = parsed
|
console.log('[ContextualAutoTag] AI response (new):', text?.substring(0, 300))
|
||||||
if (parsed.suggestions && Array.isArray(parsed.suggestions)) {
|
|
||||||
suggestionsArray = parsed.suggestions
|
|
||||||
} else if (Array.isArray(parsed)) {
|
|
||||||
suggestionsArray = parsed
|
|
||||||
} else {
|
|
||||||
console.error('[ContextualAutoTag] Invalid new-labels response structure:', parsed)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter and map suggestions
|
const parsed = this.parseAIResponse(text)
|
||||||
const suggestions = suggestionsArray
|
if (!parsed) return []
|
||||||
.filter((s: any) => {
|
|
||||||
return s.label && s.label.length > 0 && (s.confidence || 0) > 0.3
|
const suggestions = parsed
|
||||||
})
|
.filter((s: any) => s.label && s.label.length > 0 && (s.confidence || 0) > 0.3)
|
||||||
.map((s: any) => ({
|
.map((s: any) => ({
|
||||||
label: s.label,
|
label: s.label,
|
||||||
confidence: Math.round(s.confidence * 100),
|
confidence: Math.round(s.confidence * 100),
|
||||||
reasoning: s.reasoning || '',
|
reasoning: s.reasoning || '',
|
||||||
isNewLabel: true, // Mark as new label suggestion
|
isNewLabel: true,
|
||||||
}))
|
}))
|
||||||
.sort((a: any, b: any) => b.confidence - a.confidence)
|
.sort((a: any, b: any) => b.confidence - a.confidence)
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
|
|
||||||
console.log('[ContextualAutoTag] new label suggestions:', suggestions.length, suggestions.map((s: any) => `${s.label}(${s.confidence})`))
|
console.log('[ContextualAutoTag] new labels:', suggestions.map((s: any) => `${s.label}(${s.confidence})`))
|
||||||
return suggestions as LabelSuggestion[]
|
return suggestions as LabelSuggestion[]
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to suggest new labels:', error)
|
console.error('Failed to suggest new labels:', error)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Parse AI JSON response with fallback strategies */
|
||||||
* Build the AI prompt for contextual label suggestion (localized)
|
private parseAIResponse(response: string): any[] | null {
|
||||||
*/
|
try {
|
||||||
private buildPrompt(noteContent: string, notebookName: string, availableLabels: string[], contentLanguage: string): string {
|
let parsed: any
|
||||||
const labelList = availableLabels.map(l => `- ${l}`).join('\n')
|
|
||||||
|
|
||||||
return `NOTE CONTENT (language: ${contentLanguage}):
|
try {
|
||||||
${noteContent.substring(0, 1000)}
|
parsed = JSON.parse(response)
|
||||||
|
} catch {
|
||||||
|
const codeBlockMatch = response.match(/```(?:json)?\s*(\{[\s\S]*?\}|\[[\s\S]*?\])\s*```/)
|
||||||
|
if (codeBlockMatch) {
|
||||||
|
parsed = JSON.parse(codeBlockMatch[1])
|
||||||
|
} else {
|
||||||
|
const jsonMatch = response.match(/\{[\s\S]*\}/) || response.match(/\[[\s\S]*\]/)
|
||||||
|
if (!jsonMatch) return null
|
||||||
|
let cleaned = jsonMatch[0]
|
||||||
|
cleaned = cleaned.replace(/,\s*([}\]])/g, '$1')
|
||||||
|
cleaned = cleaned.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":')
|
||||||
|
parsed = JSON.parse(cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
NOTEBOOK: ${notebookName}
|
if (parsed.suggestions && Array.isArray(parsed.suggestions)) return parsed.suggestions
|
||||||
|
if (Array.isArray(parsed)) return parsed
|
||||||
AVAILABLE LABELS:
|
return null
|
||||||
${labelList}
|
} catch {
|
||||||
|
return null
|
||||||
RULES:
|
}
|
||||||
- Only suggest a label if it is DIRECTLY related to the content of THIS specific note
|
|
||||||
- Do NOT suggest a label just because it exists in the notebook
|
|
||||||
- If the note content language (${contentLanguage}) differs from the available labels, only suggest a label if it clearly applies regardless of language
|
|
||||||
- Maximum 2 suggestions
|
|
||||||
- Confidence < 0.7 = do not suggest
|
|
||||||
- If NO label is clearly relevant, return an EMPTY array
|
|
||||||
|
|
||||||
Respond in JSON:
|
|
||||||
{"suggestions":[{"label":"name","confidence":0.85,"reasoning":"why"}]}
|
|
||||||
|
|
||||||
Your response:`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the AI prompt for NEW label suggestions (when notebook is empty) (localized)
|
|
||||||
*/
|
|
||||||
private buildNewLabelsPrompt(noteContent: string, notebookName: string, contentLanguage: string): string {
|
|
||||||
return `NOTE CONTENT (language: ${contentLanguage}):
|
|
||||||
${noteContent.substring(0, 1000)}
|
|
||||||
|
|
||||||
NOTEBOOK: ${notebookName}
|
|
||||||
|
|
||||||
RULES:
|
|
||||||
- Suggest 1-3 labels to organize this note
|
|
||||||
- Labels must be SHORT (max 1-2 words)
|
|
||||||
- Labels must be in the SAME LANGUAGE as the note content (${contentLanguage})
|
|
||||||
- Labels must be lowercase
|
|
||||||
- Maximum 3 suggestions
|
|
||||||
- Confidence < 0.6 = do not suggest
|
|
||||||
|
|
||||||
IMPORTANT: Respond ONLY with valid JSON, no markdown, no code blocks.
|
|
||||||
{"suggestions":[{"label":"label_name","confidence":0.85,"reasoning":"why"}]}
|
|
||||||
|
|
||||||
Your response:`
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user