fix(quotas): unifier le décompte IA (BYOK, rollback) et combler les fuites
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m55s
CI / Deploy production (on server) (push) Successful in 36s

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:
Antigravity
2026-07-15 20:42:25 +00:00
parent 30da592ba2
commit 4fe31ebc99
75 changed files with 2949 additions and 785 deletions

View File

@@ -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),
})
}