fix(quotas): unifier le décompte IA (BYOK, rollback) et combler les fuites
Centralise la réserve via ai-quota, corrige admin unavailable (-1), brancher les routes sans quota et le host-pays brainstorm, avec usage-meter élargi, noms de clusters, MCP et ajustements dashboard/insights. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import prisma from '@/lib/prisma'
|
||||
import { embeddingService } from './embedding.service'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { withAiQuota } from '@/lib/ai-quota'
|
||||
import { upsertNoteEmbedding } from '@/lib/embeddings'
|
||||
|
||||
export interface ClusterResult {
|
||||
@@ -570,22 +571,36 @@ export class ClusteringService {
|
||||
|
||||
/**
|
||||
* Get the N most central notes from a cluster for naming purposes.
|
||||
* Falls back to any members ranked by membershipScore if none marked central.
|
||||
*/
|
||||
async getCentralNotes(clusterId: number, userId: string, n: number = 5): Promise<Array<{ noteId: string; title: string | null; content: string }>> {
|
||||
const result = await prisma.$queryRawUnsafe<Array<{ noteId: string; title: string | null; content: string }>>(
|
||||
`SELECT DISTINCT n.id AS "noteId", n.title, n.content
|
||||
const central = await prisma.$queryRawUnsafe<Array<{ noteId: string; title: string | null; content: string }>>(
|
||||
`SELECT n.id AS "noteId", n.title, n.content
|
||||
FROM "ClusterMember" cm
|
||||
INNER JOIN "Note" n ON n.id = cm."noteId"
|
||||
WHERE cm."clusterId" = $1
|
||||
AND cm."userId" = $2
|
||||
AND cm."isCentral" = true
|
||||
ORDER BY cm."membershipScore" DESC
|
||||
LIMIT $3`,
|
||||
clusterId,
|
||||
userId,
|
||||
n
|
||||
)
|
||||
if (central.length > 0) return central
|
||||
|
||||
return result
|
||||
return prisma.$queryRawUnsafe<Array<{ noteId: string; title: string | null; content: string }>>(
|
||||
`SELECT n.id AS "noteId", n.title, n.content
|
||||
FROM "ClusterMember" cm
|
||||
INNER JOIN "Note" n ON n.id = cm."noteId"
|
||||
WHERE cm."clusterId" = $1
|
||||
AND cm."userId" = $2
|
||||
ORDER BY cm."membershipScore" DESC
|
||||
LIMIT $3`,
|
||||
clusterId,
|
||||
userId,
|
||||
n
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -628,38 +643,156 @@ export class ClusteringService {
|
||||
})
|
||||
}
|
||||
|
||||
isPlaceholderClusterName(name: string | null | undefined): boolean {
|
||||
if (!name || !name.trim()) return true
|
||||
return /^cluster\s*\d+$/i.test(name.trim())
|
||||
}
|
||||
|
||||
private heuristicNameFromNotes(
|
||||
notes: Array<{ title: string | null; content: string }>,
|
||||
clusterId: number
|
||||
): string {
|
||||
for (const note of notes) {
|
||||
const title = note.title?.trim()
|
||||
if (title && title.length >= 3) {
|
||||
return title.length > 48 ? `${title.slice(0, 45)}…` : title
|
||||
}
|
||||
}
|
||||
const snippet = notes[0]?.content?.replace(/<[^>]+>/g, ' ').trim()
|
||||
if (snippet && snippet.length >= 3) {
|
||||
return snippet.length > 48 ? `${snippet.slice(0, 45)}…` : snippet
|
||||
}
|
||||
return `Thème ${clusterId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a name for a cluster using the LLM.
|
||||
* Analyzes the 5 most central notes to extract a common theme.
|
||||
* Name a cluster from in-memory notes (does NOT require ClusterMember rows in DB).
|
||||
* Critical: POST /api/clusters used to call generateClusterName *before* save → empty centrals → "Cluster 15".
|
||||
*/
|
||||
async generateClusterNameFromNotes(
|
||||
notes: Array<{ title: string | null; content: string }>,
|
||||
clusterId: number,
|
||||
userId?: string,
|
||||
): Promise<string> {
|
||||
if (notes.length === 0) {
|
||||
return this.heuristicNameFromNotes(notes, clusterId)
|
||||
}
|
||||
|
||||
const notesText = notes
|
||||
.slice(0, 5)
|
||||
.map((note, i) => `${i + 1}. "${note.title || 'Untitled'}" - ${note.content.replace(/<[^>]+>/g, ' ').slice(0, 100)}...`)
|
||||
.join('\n')
|
||||
|
||||
const systemPrompt =
|
||||
"Vous êtes un assistant d'analyse sémantique. Analysez les notes fournies et dégagez un thème commun clair, élégant et évocateur (2 à 6 mots), dans la langue principale des notes. Ne donnez QUE le titre thématique final, sans ponctuation finale, sans guillemets, sans explication. N'utilisez JAMAIS le format « Cluster 12 »."
|
||||
|
||||
const userPrompt = `Voici des notes du même groupe thématique. Quel est leur thème commun ?\n\n${notesText}\n\nThème :`
|
||||
|
||||
try {
|
||||
const runLlm = async () => {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
const response = await provider.chat(
|
||||
[{ role: 'user', content: userPrompt }],
|
||||
systemPrompt,
|
||||
)
|
||||
return response.text.trim().replace(/^["«]|["»]$/g, '').slice(0, 50)
|
||||
}
|
||||
|
||||
const named = userId
|
||||
? await withAiQuota(userId, 'reformulate', runLlm, { lane: 'chat' })
|
||||
: await runLlm()
|
||||
if (this.isPlaceholderClusterName(named)) {
|
||||
return this.heuristicNameFromNotes(notes, clusterId)
|
||||
}
|
||||
return named || this.heuristicNameFromNotes(notes, clusterId)
|
||||
} catch {
|
||||
return this.heuristicNameFromNotes(notes, clusterId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a name for a cluster using the LLM (after members are saved).
|
||||
*/
|
||||
async generateClusterName(clusterId: number, userId: string): Promise<string> {
|
||||
const centralNotes = await this.getCentralNotes(clusterId, userId, 5)
|
||||
return this.generateClusterNameFromNotes(centralNotes, clusterId, userId)
|
||||
}
|
||||
|
||||
if (centralNotes.length === 0) {
|
||||
return `Cluster ${clusterId}`
|
||||
displayName(name: string | null | undefined, clusterId: number): string {
|
||||
if (this.isPlaceholderClusterName(name)) return `Thème ${clusterId}`
|
||||
return name!.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Name clusters from in-memory results (before or without depending on DB members).
|
||||
* Prefer central notes when available.
|
||||
*/
|
||||
async nameClustersFromResults(
|
||||
userId: string,
|
||||
results: { clusters: ClusterResult[]; clusteredNotes: ClusteredNote[] }
|
||||
): Promise<void> {
|
||||
const allNoteIds = [...new Set(results.clusteredNotes.map(n => n.noteId))]
|
||||
const notes = allNoteIds.length
|
||||
? await prisma.note.findMany({
|
||||
where: { id: { in: allNoteIds }, userId },
|
||||
select: { id: true, title: true, content: true },
|
||||
})
|
||||
: []
|
||||
const byId = new Map(notes.map(n => [n.id, n]))
|
||||
|
||||
for (const cluster of results.clusters) {
|
||||
const members = results.clusteredNotes.filter(n => n.clusterId === cluster.clusterId)
|
||||
const preferred = members.filter(n => n.isCentral)
|
||||
const ordered = (preferred.length > 0 ? preferred : members)
|
||||
.sort((a, b) => b.membershipScore - a.membershipScore)
|
||||
.slice(0, 5)
|
||||
const notePayload = ordered
|
||||
.map(m => byId.get(m.noteId))
|
||||
.filter((n): n is { id: string; title: string | null; content: string } => Boolean(n))
|
||||
.map(n => ({ title: n.title, content: n.content }))
|
||||
|
||||
cluster.name = await this.generateClusterNameFromNotes(notePayload, cluster.clusterId, userId)
|
||||
}
|
||||
}
|
||||
|
||||
const notesText = centralNotes
|
||||
.map((note, i) => `${i + 1}. "${note.title || 'Untitled'}" - ${note.content.slice(0, 100)}...`)
|
||||
.join('\n')
|
||||
|
||||
const systemPrompt = "Vous êtes un assistant d'analyse sémantique. Analysez les notes fournies et dégagez un thème commun clair, élégant et évocateur (2 à 4 mots maximum), écrit en français (ou dans la langue principale des notes). Ne donnez QUE le titre thématique final, sans ponctuation, sans guillemets, et sans aucune explication."
|
||||
|
||||
const userPrompt = `Voici 5 notes centrales appartenant au même groupe thématique. Quel est leur thème commun ?\n\n${notesText}\n\nThème :`
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
const response = await provider.chat(
|
||||
[{ role: 'user', content: userPrompt }],
|
||||
systemPrompt
|
||||
)
|
||||
return response.text.trim().slice(0, 50)
|
||||
} catch {
|
||||
return `Cluster ${clusterId}`
|
||||
/**
|
||||
* Rename clusters that still have null / "Cluster N" placeholders.
|
||||
* Defaults to fast heuristic titles (central note titles) — no LLM on page load.
|
||||
* Pass useAi: true from cron/admin if needed; normal POST already names via nameClustersFromResults.
|
||||
*/
|
||||
async ensureClusterNames(userId: string, options?: { useAi?: boolean }): Promise<void> {
|
||||
const clusters = await prisma.noteCluster.findMany({
|
||||
where: { userId },
|
||||
select: { clusterId: true, name: true },
|
||||
})
|
||||
for (const cluster of clusters) {
|
||||
if (!this.isPlaceholderClusterName(cluster.name)) continue
|
||||
const name = options?.useAi
|
||||
? await this.generateClusterName(cluster.clusterId, userId)
|
||||
: this.heuristicNameFromNotes(
|
||||
await this.getCentralNotes(cluster.clusterId, userId, 5),
|
||||
cluster.clusterId
|
||||
)
|
||||
await prisma.noteCluster.updateMany({
|
||||
where: { userId, clusterId: cluster.clusterId },
|
||||
data: { name },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Current clusterId → human name map (never "Cluster N").
|
||||
*/
|
||||
async getClusterNameMap(userId: string): Promise<Map<number, string>> {
|
||||
await this.ensureClusterNames(userId)
|
||||
const clusters = await prisma.noteCluster.findMany({
|
||||
where: { userId },
|
||||
select: { clusterId: true, name: true },
|
||||
})
|
||||
return new Map(clusters.map(c => [c.clusterId, this.displayName(c.name, c.clusterId)]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if recalculation is needed based on data change percentage.
|
||||
*/
|
||||
@@ -723,7 +856,7 @@ export class ClusteringService {
|
||||
result.push({
|
||||
clusterId: cluster.clusterId,
|
||||
noteIds: members.map(m => m.noteId),
|
||||
name: cluster.name || undefined
|
||||
name: this.displayName(cluster.name, cluster.clusterId),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user