Keep/keep-notes/lib/ai/services/batch-organization.service.ts
sepehr 3dd48e248c fix: 8 AI services were not using configured provider
CRITICAL FIX: Auto-labels, notebook summaries, and other AI features
were not working because 8 services were calling getAIProvider() WITHOUT
passing the config parameter.

This caused them to use the default 'ollama' provider instead of
the configured OpenAI provider from the database.

ROOT CAUSE ANALYSIS:
Working features (titles):
- title-suggestions/route.ts: getAIProvider(config) ✓

Broken features (auto-labels, summaries):
- contextual-auto-tag.service.ts: getAIProvider() ✗ (2x)
- notebook-summary.service.ts: getAIProvider() ✗
- auto-label-creation.service.ts: getAIProvider() ✗
- notebook-suggestion.service.ts: getAIProvider() ✗
- batch-organization.service.ts: getAIProvider() ✗
- embedding.service.ts: getAIProvider() ✗ (2x)

FIXED: All 8 services now properly call:
  const config = await getSystemConfig()
  const provider = getAIProvider(config)

This ensures ALL AI features use the provider configured in the admin
interface (OpenAI) instead of defaulting to Ollama.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-12 23:34:16 +01:00

308 lines
8.0 KiB
TypeScript

import { prisma } from '@/lib/prisma'
import { getAIProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
export interface NoteForOrganization {
id: string
title: string | null
content: string
}
export interface NotebookOrganization {
notebookId: string
notebookName: string
notebookIcon: string | null
notebookColor: string | null
notes: Array<{
noteId: string
title: string | null
content: string
confidence: number
reason: string
}>
}
export interface OrganizationPlan {
notebooks: NotebookOrganization[]
totalNotes: number
unorganizedNotes: number // Notes that couldn't be categorized
}
/**
* Service for batch organizing notes from "Notes générales" into notebooks
* (Story 5.3 - IA3)
*/
export class BatchOrganizationService {
/**
* Analyze all notes in "Notes générales" and create an organization plan
* @param userId - User ID
* @returns Organization plan with notebook assignments
*/
async createOrganizationPlan(userId: string): Promise<OrganizationPlan> {
// 1. Get all notes without notebook (Inbox/Notes générales)
const notesWithoutNotebook = await prisma.note.findMany({
where: {
userId,
notebookId: null,
},
select: {
id: true,
title: true,
content: true,
},
orderBy: {
updatedAt: 'desc',
},
take: 50, // Limit to 50 notes for AI processing
})
if (notesWithoutNotebook.length === 0) {
return {
notebooks: [],
totalNotes: 0,
unorganizedNotes: 0,
}
}
// 2. Get all user's notebooks
const notebooks = await prisma.notebook.findMany({
where: { userId },
include: {
labels: true,
_count: {
select: { notes: true },
},
},
orderBy: { order: 'asc' },
})
if (notebooks.length === 0) {
// No notebooks to organize into
return {
notebooks: [],
totalNotes: notesWithoutNotebook.length,
unorganizedNotes: notesWithoutNotebook.length,
}
}
// 3. Call AI to create organization plan
const plan = await this.aiOrganizeNotes(notesWithoutNotebook, notebooks)
return plan
}
/**
* Use AI to analyze notes and create organization plan
*/
private async aiOrganizeNotes(
notes: NoteForOrganization[],
notebooks: any[]
): Promise<OrganizationPlan> {
const prompt = this.buildPrompt(notes, notebooks)
try {
const config = await getSystemConfig()
const provider = getAIProvider(config)
const response = await provider.generateText(prompt)
// Parse AI response
const plan = this.parseAIResponse(response, notes, notebooks)
return plan
} catch (error) {
console.error('Failed to create organization plan:', error)
// Return empty plan on error
return {
notebooks: [],
totalNotes: notes.length,
unorganizedNotes: notes.length,
}
}
}
/**
* Build prompt for AI (always in French - interface language)
*/
private buildPrompt(notes: NoteForOrganization[], notebooks: any[]): string {
const notebookList = notebooks
.map(nb => {
const labels = nb.labels.map((l: any) => l.name).join(', ')
const count = nb._count?.notes || 0
return `- ${nb.name} (${count} notes)${labels ? ` [labels: ${labels}]` : ''}`
})
.join('\n')
const notesList = notes
.map((note, index) => {
const title = note.title || 'Sans titre'
const content = note.content.substring(0, 200)
return `[${index}] "${title}": ${content}`
})
.join('\n')
return `
Tu es un assistant qui organise des notes en les regroupant par thématique dans des carnets.
CARNETS DISPONIBLES :
${notebookList}
NOTES À ORGANISER (Notes générales) :
${notesList}
TÂCHE :
Analyse chaque note et propose le carnet le PLUS approprié.
Considère :
1. Le sujet/thème de la note (LE PLUS IMPORTANT)
2. Les labels existants dans chaque carnet
3. La cohérence thématique entre notes du même carnet
GUIDES DE CLASSIFICATION :
- SPORT/EXERCICE/ACHATS/COURSSES → Carnet Personnel
- LOISIRS/PASSIONS/SORTIES → Carnet Personnel
- SANTÉ/FITNESS/MÉDECIN → Carnet Personnel ou Santé
- FAMILLE/AMIS → Carnet Personnel
- TRAVAIL/RÉUNIONS/PROJETS/CLIENTS → Carnet Travail
- CODING/TECH/DÉVELOPPEMENT → Carnet Travail ou Code
- FINANCES/FACTURES/BANQUE → Carnet Personnel ou Finances
FORMAT DE RÉPONSE (JSON) :
Pour chaque carnet, liste les notes qui lui appartiennent :
{
"carnets": [
{
"nom": "Nom du carnet",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "Courte explication"
}
]
}
]
}
RÈGLES :
- Seules les notes avec confiance > 0.60 doivent être assignées
- Si une note est trop générique, ne l'assigne pas
- Sois précis dans tes regroupements thématiques
Ta réponse (JSON seulement) :
`.trim()
}
/**
* Parse AI response into OrganizationPlan
*/
private parseAIResponse(
response: string,
notes: NoteForOrganization[],
notebooks: any[]
): OrganizationPlan {
try {
// Try to parse JSON response
const jsonMatch = response.match(/\{[\s\S]*\}/)
if (!jsonMatch) {
throw new Error('No JSON found in response')
}
const aiData = JSON.parse(jsonMatch[0])
const notebookOrganizations: NotebookOrganization[] = []
// Process each notebook in AI response
for (const aiNotebook of aiData.carnets || []) {
const notebook = notebooks.find(nb => nb.name === aiNotebook.nom)
if (!notebook) continue
const noteAssignments = aiNotebook.notes
.filter((n: any) => n.confiance > 0.60) // Only high confidence
.map((n: any) => {
const note = notes[n.index]
if (!note) return null
return {
noteId: note.id,
title: note.title,
content: note.content,
confidence: n.confiance,
reason: n.raison || '',
}
})
.filter(Boolean)
if (noteAssignments.length > 0) {
notebookOrganizations.push({
notebookId: notebook.id,
notebookName: notebook.name,
notebookIcon: notebook.icon,
notebookColor: notebook.color,
notes: noteAssignments,
})
}
}
// Count unorganized notes
const organizedNoteIds = new Set(
notebookOrganizations.flatMap(nb => nb.notes.map(n => n.noteId))
)
const unorganizedCount = notes.length - organizedNoteIds.size
return {
notebooks: notebookOrganizations,
totalNotes: notes.length,
unorganizedNotes: unorganizedCount,
}
} catch (error) {
console.error('Failed to parse AI response:', error)
return {
notebooks: [],
totalNotes: notes.length,
unorganizedNotes: notes.length,
}
}
}
/**
* Apply the organization plan (move notes to notebooks)
* @param userId - User ID
* @param plan - Organization plan to apply
* @param selectedNoteIds - Specific note IDs to organize (user can deselect)
* @returns Number of notes moved
*/
async applyOrganizationPlan(
userId: string,
plan: OrganizationPlan,
selectedNoteIds: string[]
): Promise<number> {
let movedCount = 0
for (const notebookOrg of plan.notebooks) {
// Filter notes that are selected
const notesToMove = notebookOrg.notes.filter(n =>
selectedNoteIds.includes(n.noteId)
)
if (notesToMove.length === 0) continue
// Move notes to notebook
await prisma.note.updateMany({
where: {
id: { in: notesToMove.map(n => n.noteId) },
userId,
},
data: {
notebookId: notebookOrg.notebookId,
},
})
movedCount += notesToMove.length
}
return movedCount
}
}
// Export singleton instance
export const batchOrganizationService = new BatchOrganizationService()