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:
@@ -17,6 +17,8 @@ import { extractAndDownloadImages, extractImageUrlsFromHtml, downloadImage } fro
|
||||
import { calculateNextRun } from '@/lib/agents/schedule'
|
||||
import { markdownToHtml } from '@/lib/markdown-to-html'
|
||||
import { createNotification } from '@/app/actions/notifications'
|
||||
import { withAiQuota } from '@/lib/ai-quota'
|
||||
import type { FeatureName } from '@/lib/quota-utils'
|
||||
|
||||
// Import tools for side-effect registration
|
||||
import '../tools'
|
||||
@@ -25,6 +27,12 @@ import '../tools'
|
||||
|
||||
export type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom' | 'slide-generator' | 'excalidraw-generator' | 'task-extractor'
|
||||
|
||||
function quotaFeatureForAgentType(type: string): FeatureName {
|
||||
if (type === 'slide-generator') return 'slide_generate'
|
||||
if (type === 'excalidraw-generator') return 'excalidraw_generate'
|
||||
return 'reformulate'
|
||||
}
|
||||
|
||||
export interface AgentExecutionResult {
|
||||
success: boolean
|
||||
actionId: string
|
||||
@@ -1759,36 +1767,45 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
|
||||
|
||||
// Detect user language
|
||||
const lang = await getUserLanguage(userId)
|
||||
const quotaFeature = quotaFeatureForAgentType(agent.type || 'scraper')
|
||||
|
||||
try {
|
||||
let result: AgentExecutionResult
|
||||
const result = await withAiQuota(
|
||||
userId,
|
||||
quotaFeature,
|
||||
async () => {
|
||||
let inner: AgentExecutionResult
|
||||
|
||||
const hasTools = agent.tools && agent.tools !== '[]' && agent.tools !== 'null'
|
||||
|
||||
if (hasTools) {
|
||||
result = await executeToolUseAgent(agent, action.id, lang, promptOverride)
|
||||
inner = await executeToolUseAgent(agent, action.id, lang, promptOverride)
|
||||
} else {
|
||||
switch ((agent.type || 'scraper') as AgentType) {
|
||||
case 'scraper':
|
||||
result = await executeScraperAgent(agent, action.id, lang)
|
||||
inner = await executeScraperAgent(agent, action.id, lang)
|
||||
break
|
||||
case 'researcher':
|
||||
result = await executeResearcherAgent(agent, action.id, lang)
|
||||
inner = await executeResearcherAgent(agent, action.id, lang)
|
||||
break
|
||||
case 'monitor':
|
||||
result = await executeMonitorAgent(agent, action.id, lang)
|
||||
inner = await executeMonitorAgent(agent, action.id, lang)
|
||||
break
|
||||
case 'custom':
|
||||
result = await executeCustomAgent(agent, action.id, lang)
|
||||
inner = await executeCustomAgent(agent, action.id, lang)
|
||||
break
|
||||
case 'slide-generator':
|
||||
case 'excalidraw-generator':
|
||||
result = await executeToolUseAgent(agent, action.id, lang, promptOverride)
|
||||
inner = await executeToolUseAgent(agent, action.id, lang, promptOverride)
|
||||
break
|
||||
default:
|
||||
result = await executeScraperAgent(agent, action.id, lang)
|
||||
inner = await executeScraperAgent(agent, action.id, lang)
|
||||
}
|
||||
}
|
||||
return inner
|
||||
},
|
||||
{ lane: 'chat' },
|
||||
)
|
||||
|
||||
const nextRunUpdate: Record<string, Date | null> = {}
|
||||
if (agent.frequency !== 'manual') {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { clusteringService } from './clustering.service'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { calculateNextRun } from '@/lib/agents/schedule'
|
||||
import { withAiQuota } from '@/lib/ai-quota'
|
||||
|
||||
const MIN_CLUSTER_NOTES = 3
|
||||
const MAX_SUGGESTIONS_PER_RUN = 3
|
||||
@@ -51,6 +52,7 @@ function clusterCoveredByAgent(
|
||||
}
|
||||
|
||||
async function buildSuggestionWithLlm(
|
||||
userId: string,
|
||||
topic: string,
|
||||
noteTitles: string[],
|
||||
noteCount: number,
|
||||
@@ -63,10 +65,6 @@ async function buildSuggestionWithLlm(
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
if (!provider) return fallback
|
||||
|
||||
const prompt = `Tu proposes un agent IA pour un second cerveau (prise de notes).
|
||||
Thème détecté: "${topic}"
|
||||
Notes liées (titres): ${noteTitles.slice(0, 5).join(' | ') || 'sans titre'}
|
||||
@@ -75,7 +73,14 @@ Nombre de notes: ${noteCount}
|
||||
Retourne UNIQUEMENT du JSON valide:
|
||||
{"reason":"1 phrase pourquoi un agent est utile","suggestedRole":"prompt système de l'agent (2-3 phrases, français)","suggestedType":"researcher|monitor","suggestedFrequency":"daily|weekly"}`
|
||||
|
||||
const raw = await provider.generateText(prompt)
|
||||
const raw = await withAiQuota(userId, 'reformulate', async () => {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
if (!provider) return null
|
||||
return provider.generateText(prompt)
|
||||
}, { lane: 'chat' })
|
||||
|
||||
if (!raw) return fallback
|
||||
const match = raw.match(/\{[\s\S]*\}/)
|
||||
if (!match) return fallback
|
||||
const parsed = JSON.parse(match[0])
|
||||
@@ -122,6 +127,7 @@ export class AgentSuggestionService {
|
||||
select: { title: true },
|
||||
})
|
||||
const llm = await buildSuggestionWithLlm(
|
||||
userId,
|
||||
topic,
|
||||
notes.map(n => n.title || 'Sans titre'),
|
||||
cluster.noteIds.length,
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
/**
|
||||
* Bridge Notes Service
|
||||
*
|
||||
* Detects and manages "bridge notes" — notes that connect multiple clusters.
|
||||
* A bridge note has strong similarities (cosine > 0.5) with notes from
|
||||
* at least two different clusters.
|
||||
* Detects and manages "bridge notes" — notes that connect multiple thematic clusters.
|
||||
*
|
||||
* Also generates AI-powered suggestions for creating new bridge notes
|
||||
* to connect isolated clusters.
|
||||
* Scientific grounding (semantic similarity networks / brokerage):
|
||||
* - A bridge document is valued when it spans *distinct communities*, typically the
|
||||
* strongest link between a *pair* of clusters — not weak ties to every theme
|
||||
* (see semantic similarity network bridging; betweenness / cross-community brokerage).
|
||||
* - Literature commonly uses cosine thresholds ≈ 0.65–0.85 for semantic edges
|
||||
* (Vec2GC, PMC semantic graphs ~0.65; looser 0.5 floods the graph with false bridges).
|
||||
* - Score = mean affinity to the top-2 clusters (not "% of all themes touched").
|
||||
*/
|
||||
|
||||
import prisma from '@/lib/prisma'
|
||||
import { clusteringService } from './clustering.service'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { withAiQuota } from '@/lib/ai-quota'
|
||||
|
||||
export interface BridgeNote {
|
||||
noteId: string
|
||||
/** 0–1 mean cosine affinity to the top bridge clusters (typically a pair). */
|
||||
bridgeScore: number
|
||||
/** Cluster ids ranked by affinity (strongest first). Prefer length 2. */
|
||||
clustersConnected: number[]
|
||||
clusterNames?: string[]
|
||||
/** Per-cluster max cosine affinities, aligned with clustersConnected. */
|
||||
clusterAffinities?: number[]
|
||||
}
|
||||
|
||||
export interface BridgeSuggestion {
|
||||
@@ -31,26 +39,37 @@ export interface BridgeSuggestion {
|
||||
justification: string
|
||||
}
|
||||
|
||||
interface ClusterAffinity {
|
||||
clusterId: number
|
||||
maxSimilarity: number
|
||||
hitCount: number
|
||||
}
|
||||
|
||||
export class BridgeNotesService {
|
||||
private readonly BRIDGE_SIMILARITY_THRESHOLD = 0.5
|
||||
/** Cosine similarity floor for semantic edges (literature ≈ 0.65). */
|
||||
private readonly BRIDGE_SIMILARITY_THRESHOLD = 0.65
|
||||
private readonly MIN_CLUSTERS_FOR_BRIDGE = 2
|
||||
/** A true bridge is primarily a pair of communities (brokerage). */
|
||||
private readonly MAX_BRIDGED_CLUSTERS = 2
|
||||
|
||||
/**
|
||||
* Get similar notes for a given note across all clusters.
|
||||
* Returns notes grouped by their cluster membership.
|
||||
* For one note, return max cosine similarity to each cluster that clears the threshold.
|
||||
*/
|
||||
private async getSimilarNotesByCluster(
|
||||
private async getClusterAffinities(
|
||||
noteId: string,
|
||||
userId: string,
|
||||
threshold: number = this.BRIDGE_SIMILARITY_THRESHOLD
|
||||
): Promise<Map<number, string[]>> {
|
||||
): Promise<ClusterAffinity[]> {
|
||||
const cosineDistance = 1 - threshold
|
||||
|
||||
const result = await prisma.$queryRawUnsafe<Array<{
|
||||
noteId: string
|
||||
clusterId: number | null
|
||||
similarity: number
|
||||
}>>(
|
||||
`SELECT e2."noteId", cm."clusterId"
|
||||
`SELECT e2."noteId",
|
||||
cm."clusterId",
|
||||
(1 - (e1."embedding"::vector <=> e2."embedding"::vector))::float8 AS similarity
|
||||
FROM "NoteEmbedding" e1
|
||||
CROSS JOIN "NoteEmbedding" e2
|
||||
INNER JOIN "Note" n ON n.id = e2."noteId"
|
||||
@@ -65,25 +84,36 @@ export class BridgeNotesService {
|
||||
cosineDistance
|
||||
)
|
||||
|
||||
const clusterMap = new Map<number, string[]>()
|
||||
const byCluster = new Map<number, { maxSimilarity: number; hitCount: number }>()
|
||||
|
||||
for (const row of result) {
|
||||
const clusterId = row.clusterId ?? -1 // -1 for noise/uncategorized
|
||||
if (!clusterMap.has(clusterId)) {
|
||||
clusterMap.set(clusterId, [])
|
||||
const clusterId = row.clusterId
|
||||
if (clusterId === null || clusterId === -1) continue
|
||||
const sim = Number(row.similarity)
|
||||
if (!Number.isFinite(sim) || sim < threshold) continue
|
||||
const prev = byCluster.get(clusterId)
|
||||
if (!prev) {
|
||||
byCluster.set(clusterId, { maxSimilarity: sim, hitCount: 1 })
|
||||
} else {
|
||||
prev.maxSimilarity = Math.max(prev.maxSimilarity, sim)
|
||||
prev.hitCount += 1
|
||||
}
|
||||
clusterMap.get(clusterId)!.push(row.noteId)
|
||||
}
|
||||
|
||||
return clusterMap
|
||||
return [...byCluster.entries()]
|
||||
.map(([clusterId, v]) => ({
|
||||
clusterId,
|
||||
maxSimilarity: v.maxSimilarity,
|
||||
hitCount: v.hitCount,
|
||||
}))
|
||||
.sort((a, b) => b.maxSimilarity - a.maxSimilarity || b.hitCount - a.hitCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all bridge notes for a user.
|
||||
* A note is a bridge if it has similarities to >= 2 distinct clusters.
|
||||
* Detect bridge notes: notes with strong affinity to at least two clusters.
|
||||
* Keeps only the top-2 clusters (pair brokerage) and scores by their mean affinity.
|
||||
*/
|
||||
async detectBridgeNotes(userId: string): Promise<BridgeNote[]> {
|
||||
// Get all user's clusters
|
||||
const clusters = await prisma.noteCluster.findMany({
|
||||
where: { userId },
|
||||
select: { clusterId: true, name: true },
|
||||
@@ -94,42 +124,40 @@ export class BridgeNotesService {
|
||||
return []
|
||||
}
|
||||
|
||||
const maxClusters = clusters.length
|
||||
const nameById = new Map(
|
||||
clusters.map(c => [c.clusterId, clusteringService.displayName(c.name, c.clusterId)])
|
||||
)
|
||||
const bridgeNotes: BridgeNote[] = []
|
||||
|
||||
// Check each note for bridge potential
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { userId, trashedAt: null },
|
||||
select: { id: true }
|
||||
})
|
||||
|
||||
for (const note of notes) {
|
||||
const similarByCluster = await this.getSimilarNotesByCluster(note.id, userId)
|
||||
const affinities = await this.getClusterAffinities(note.id, userId)
|
||||
if (affinities.length < this.MIN_CLUSTERS_FOR_BRIDGE) continue
|
||||
|
||||
// Filter out noise (-1) and get clusters with actual similar notes
|
||||
const clustersWithSimilarNotes: number[] = []
|
||||
for (const [clusterId, similarNotes] of similarByCluster) {
|
||||
if (clusterId !== -1 && similarNotes.length > 0) {
|
||||
clustersWithSimilarNotes.push(clusterId)
|
||||
}
|
||||
}
|
||||
const top = affinities.slice(0, this.MAX_BRIDGED_CLUSTERS)
|
||||
// Both legs of the bridge must be strong
|
||||
if (top.length < this.MIN_CLUSTERS_FOR_BRIDGE) continue
|
||||
if (top.some(a => a.maxSimilarity < this.BRIDGE_SIMILARITY_THRESHOLD)) continue
|
||||
|
||||
// Check if this note connects >= 2 clusters
|
||||
if (clustersWithSimilarNotes.length >= this.MIN_CLUSTERS_FOR_BRIDGE) {
|
||||
const bridgeScore = clustersWithSimilarNotes.length / maxClusters
|
||||
const bridgeScore =
|
||||
top.reduce((sum, a) => sum + a.maxSimilarity, 0) / top.length
|
||||
|
||||
bridgeNotes.push({
|
||||
noteId: note.id,
|
||||
bridgeScore,
|
||||
clustersConnected: clustersWithSimilarNotes,
|
||||
clusterNames: clustersWithSimilarNotes.map(
|
||||
cid => clusters.find(c => c.clusterId === cid)?.name || `Cluster ${cid}`
|
||||
)
|
||||
})
|
||||
}
|
||||
const clustersConnected = top.map(a => a.clusterId)
|
||||
bridgeNotes.push({
|
||||
noteId: note.id,
|
||||
bridgeScore,
|
||||
clustersConnected,
|
||||
clusterNames: clustersConnected.map(
|
||||
cid => nameById.get(cid) || clusteringService.displayName(null, cid)
|
||||
),
|
||||
clusterAffinities: top.map(a => a.maxSimilarity),
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by bridge score (most influential first)
|
||||
return bridgeNotes.sort((a, b) => b.bridgeScore - a.bridgeScore)
|
||||
}
|
||||
|
||||
@@ -179,10 +207,22 @@ export class BridgeNotesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI-powered suggestions for connecting isolated clusters.
|
||||
* Generate AI suggestions for *plausible* missing links between clusters.
|
||||
*
|
||||
* Scientific grounding (link prediction / KG completion):
|
||||
* - Do NOT propose a link for every unconnected pair (combinatorial explosion →
|
||||
* forced metaphors like "Stripe ↔ gas dynamics").
|
||||
* - Pre-filter by centroid cosine similarity: keep "near-miss" pairs that are
|
||||
* related enough to deserve a bridge, but not already tightly fused.
|
||||
* - Cap candidate count; rank by structural proximity before spending AI calls.
|
||||
* - Prompt: grounded synthesis only; return null if no real shared object/method.
|
||||
*/
|
||||
async generateBridgeSuggestions(userId: string): Promise<BridgeSuggestion[]> {
|
||||
// Get all clusters
|
||||
const MAX_SUGGESTIONS = 3
|
||||
/** Near-miss only — wide band (0.30–0.62) still let Stripe↔gaz through. */
|
||||
const MIN_PAIR_SIMILARITY = 0.45
|
||||
const MAX_PAIR_SIMILARITY = 0.58
|
||||
|
||||
const clusters = await prisma.noteCluster.findMany({
|
||||
where: { userId },
|
||||
select: { clusterId: true, name: true },
|
||||
@@ -191,116 +231,254 @@ export class BridgeNotesService {
|
||||
|
||||
if (clusters.length < 2) return []
|
||||
|
||||
// Get existing bridges to see which clusters are already connected
|
||||
const existingBridges = await prisma.bridgeNote.findMany({
|
||||
where: { userId },
|
||||
select: { clustersConnected: true }
|
||||
})
|
||||
|
||||
const connectedPairs = new Set<string>()
|
||||
|
||||
for (const bridge of existingBridges) {
|
||||
const clusters = JSON.parse(bridge.clustersConnected) as number[]
|
||||
for (let i = 0; i < clusters.length; i++) {
|
||||
for (let j = i + 1; j < clusters.length; j++) {
|
||||
const pair = [clusters[i], clusters[j]].sort().join('-')
|
||||
connectedPairs.add(pair)
|
||||
const ids = JSON.parse(bridge.clustersConnected) as number[]
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
connectedPairs.add([ids[i], ids[j]].sort((a, b) => a - b).join('-'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find unconnected cluster pairs
|
||||
const suggestions: BridgeSuggestion[] = []
|
||||
const centroids = new Map<number, number[]>()
|
||||
const summaries = new Map<number, string>()
|
||||
await Promise.all(
|
||||
clusters.map(async c => {
|
||||
const [centroid, summary] = await Promise.all([
|
||||
this.getClusterCentroid(c.clusterId, userId),
|
||||
this.getClusterSummary(c.clusterId, userId),
|
||||
])
|
||||
if (centroid) centroids.set(c.clusterId, centroid)
|
||||
summaries.set(c.clusterId, summary)
|
||||
})
|
||||
)
|
||||
|
||||
type RankedPair = {
|
||||
a: typeof clusters[0]
|
||||
b: typeof clusters[0]
|
||||
similarity: number
|
||||
}
|
||||
const ranked: RankedPair[] = []
|
||||
|
||||
for (let i = 0; i < clusters.length; i++) {
|
||||
for (let j = i + 1; j < clusters.length; j++) {
|
||||
const pair = `${clusters[i].clusterId}-${clusters[j].clusterId}`
|
||||
const a = clusters[i]
|
||||
const b = clusters[j]
|
||||
const pairKey = [a.clusterId, b.clusterId].sort((x, y) => x - y).join('-')
|
||||
if (connectedPairs.has(pairKey)) continue
|
||||
|
||||
if (connectedPairs.has(pair)) continue // Already connected
|
||||
const ca = centroids.get(a.clusterId)
|
||||
const cb = centroids.get(b.clusterId)
|
||||
if (!ca || !cb) continue
|
||||
|
||||
// Generate suggestion for this unconnected pair
|
||||
const suggestion = await this.generateConnectionSuggestion(
|
||||
clusters[i].clusterId,
|
||||
clusters[j].clusterId,
|
||||
clusters[i].name || `Cluster ${clusters[i].clusterId}`,
|
||||
clusters[j].name || `Cluster ${clusters[j].clusterId}`,
|
||||
userId
|
||||
)
|
||||
const similarity = this.cosineSimilarity(ca, cb)
|
||||
if (similarity < MIN_PAIR_SIMILARITY || similarity > MAX_PAIR_SIMILARITY) continue
|
||||
|
||||
if (suggestion) {
|
||||
suggestions.push(suggestion)
|
||||
// Lexical gate: no shared vocabulary → no suggestion (blocks Stripe↔gaz)
|
||||
if (!this.hasLexicalOverlap(summaries.get(a.clusterId) || '', summaries.get(b.clusterId) || '')) {
|
||||
continue
|
||||
}
|
||||
|
||||
ranked.push({ a, b, similarity })
|
||||
}
|
||||
}
|
||||
|
||||
ranked.sort((x, y) => y.similarity - x.similarity)
|
||||
const candidates = ranked.slice(0, MAX_SUGGESTIONS)
|
||||
|
||||
const suggestions: BridgeSuggestion[] = []
|
||||
for (const { a, b, similarity } of candidates) {
|
||||
const suggestion = await this.generateConnectionSuggestion(
|
||||
a.clusterId,
|
||||
b.clusterId,
|
||||
clusteringService.displayName(a.name, a.clusterId),
|
||||
clusteringService.displayName(b.name, b.clusterId),
|
||||
userId,
|
||||
similarity
|
||||
)
|
||||
if (suggestion && !this.looksLikeForcedMetaphor(suggestion)) {
|
||||
suggestions.push(suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
/** Shared tokens in note snippets — blocks ornamental cross-domain pairs. */
|
||||
private hasLexicalOverlap(textA: string, textB: string): boolean {
|
||||
const stop = new Set([
|
||||
'the', 'and', 'for', 'with', 'that', 'this', 'from', 'are', 'was', 'were', 'have', 'has',
|
||||
'les', 'des', 'une', 'dans', 'pour', 'avec', 'que', 'qui', 'sur', 'par', 'est', 'sont',
|
||||
'not', 'note', 'notes', 'untitled', 'sans', 'titre', 'cette', 'plus', 'comme',
|
||||
])
|
||||
const tokens = (text: string) =>
|
||||
new Set(
|
||||
text
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.split(/[^a-z0-9]+/i)
|
||||
.filter(t => t.length >= 4 && !stop.has(t))
|
||||
)
|
||||
const a = tokens(textA)
|
||||
const b = tokens(textB)
|
||||
if (a.size === 0 || b.size === 0) return false
|
||||
let shared = 0
|
||||
for (const t of a) {
|
||||
if (b.has(t)) shared += 1
|
||||
if (shared >= 2) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private looksLikeForcedMetaphor(s: BridgeSuggestion): boolean {
|
||||
const blob = `${s.suggestedTitle} ${s.suggestedContent} ${s.justification}`.toLowerCase()
|
||||
const redFlags = [
|
||||
'analogie', 'analogy', 'métaphore', 'metaphor', 'comme si', 'as if',
|
||||
'parallel between', 'parallèle entre', 'friction', 'entropie', 'entropy',
|
||||
'fluide', 'fluid dynamics', 'rate limit', 'rate-limiting', 'adiabatique',
|
||||
'viscosity', 'viscosité', 'théorie de la friction', 'meets digital',
|
||||
]
|
||||
return redFlags.some(f => blob.includes(f))
|
||||
}
|
||||
|
||||
private cosineSimilarity(a: number[], b: number[]): number {
|
||||
if (a.length === 0 || a.length !== b.length) return 0
|
||||
let dot = 0
|
||||
let na = 0
|
||||
let nb = 0
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i] * b[i]
|
||||
na += a[i] * a[i]
|
||||
nb += b[i] * b[i]
|
||||
}
|
||||
const den = Math.sqrt(na) * Math.sqrt(nb)
|
||||
return den === 0 ? 0 : dot / den
|
||||
}
|
||||
|
||||
private async getClusterCentroid(clusterId: number, userId: string): Promise<number[] | null> {
|
||||
const rows = await prisma.$queryRawUnsafe<Array<{ embedding: string }>>(
|
||||
`SELECT e."embedding"::text AS embedding
|
||||
FROM "ClusterMember" cm
|
||||
INNER JOIN "NoteEmbedding" e ON e."noteId" = cm."noteId"
|
||||
WHERE cm."clusterId" = $1
|
||||
AND cm."userId" = $2
|
||||
AND e."embedding" IS NOT NULL
|
||||
LIMIT 40`,
|
||||
clusterId,
|
||||
userId
|
||||
)
|
||||
|
||||
const vectors: number[][] = []
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const v = JSON.parse(row.embedding) as number[]
|
||||
if (Array.isArray(v) && v.length > 0) vectors.push(v)
|
||||
} catch {
|
||||
/* skip bad vector */
|
||||
}
|
||||
}
|
||||
if (vectors.length === 0) return null
|
||||
|
||||
const dim = vectors[0].length
|
||||
const centroid = new Array(dim).fill(0)
|
||||
for (const v of vectors) {
|
||||
if (v.length !== dim) continue
|
||||
for (let i = 0; i < dim; i++) centroid[i] += v[i]
|
||||
}
|
||||
for (let i = 0; i < dim; i++) centroid[i] /= vectors.length
|
||||
return centroid
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a specific connection suggestion between two clusters.
|
||||
* Generate a grounded connection suggestion between two near-miss clusters.
|
||||
*/
|
||||
private async generateConnectionSuggestion(
|
||||
clusterAId: number,
|
||||
clusterBId: number,
|
||||
clusterAName: string,
|
||||
clusterBName: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
pairSimilarity: number
|
||||
): Promise<BridgeSuggestion | null> {
|
||||
const summaryA = await this.getClusterSummary(clusterAId, userId)
|
||||
const summaryB = await this.getClusterSummary(clusterBId, userId)
|
||||
|
||||
const systemPrompt = `You are a creative assistant that helps users connect ideas.
|
||||
Suggest a "bridge note" that could connect two unrelated topics.
|
||||
Be specific and creative. Your suggestions should help users discover new insights.`
|
||||
const systemPrompt = `You help a personal knowledge base propose MISSING LINKS (link prediction).
|
||||
Rules:
|
||||
- ONLY suggest a bridge when both themes share a concrete object, tool, method, dataset, or decision visible in the notes.
|
||||
- NEVER invent witty analogies between unrelated domains (billing ≠ thermodynamics, hosting ≠ air conditioning).
|
||||
- If themes are about different products/domains with no shared practice, return {"viable": false}.
|
||||
- Write in the same language as the note summaries.
|
||||
- Be concise and actionable.`
|
||||
|
||||
const userPrompt = `I have two groups of notes that are not connected:
|
||||
const userPrompt = `Two thematic clusters are unconnected but somewhat related (centroid cosine ≈ ${pairSimilarity.toFixed(2)}).
|
||||
|
||||
Group A (${clusterAName}):
|
||||
Theme A (${clusterAName}):
|
||||
${summaryA}
|
||||
|
||||
Group B (${clusterBName}):
|
||||
Theme B (${clusterBName}):
|
||||
${summaryB}
|
||||
|
||||
Suggest 3 creative bridge note ideas to connect these groups. For each idea, provide:
|
||||
1. A catchy title (max 10 words)
|
||||
2. A brief description of what the note would contain (max 50 words)
|
||||
3. A justification for why this connection is valuable (max 30 words)
|
||||
Propose ONE bridge note that a thoughtful researcher would actually write — a synthesis, comparison with a shared criterion, or decision note — NOT a witty forced analogy.
|
||||
|
||||
Format as JSON:
|
||||
Return ONLY JSON:
|
||||
{
|
||||
"ideas": [
|
||||
{"title": "...", "description": "...", "justification": "..."},
|
||||
...
|
||||
]
|
||||
"viable": true,
|
||||
"title": "≤12 words",
|
||||
"description": "≤40 words: what the note covers",
|
||||
"justification": "≤25 words: why this link is real (shared object/method)"
|
||||
}
|
||||
|
||||
Return ONLY the JSON, no other text.`
|
||||
or {"viable": false}`
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
const response = await provider.chat(
|
||||
[{ role: 'user', content: userPrompt }],
|
||||
systemPrompt
|
||||
)
|
||||
const response = await withAiQuota(userId, 'reformulate', async () => {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
return provider.chat(
|
||||
[{ role: 'user', content: userPrompt }],
|
||||
systemPrompt,
|
||||
)
|
||||
}, { lane: 'chat' })
|
||||
|
||||
// Parse JSON response
|
||||
const jsonMatch = response.text.match(/\{[\s\S]*\}/)
|
||||
if (!jsonMatch) return null
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0])
|
||||
const bestIdea = parsed.ideas?.[0]
|
||||
const parsed = JSON.parse(jsonMatch[0]) as {
|
||||
viable?: boolean
|
||||
title?: string
|
||||
description?: string
|
||||
justification?: string
|
||||
ideas?: Array<{ title: string; description: string; justification: string }>
|
||||
}
|
||||
|
||||
if (!bestIdea) return null
|
||||
if (parsed.viable === false) return null
|
||||
|
||||
const idea =
|
||||
parsed.title && parsed.description
|
||||
? {
|
||||
title: parsed.title,
|
||||
description: parsed.description,
|
||||
justification: parsed.justification || '',
|
||||
}
|
||||
: parsed.ideas?.[0]
|
||||
|
||||
if (!idea?.title || !idea?.description) return null
|
||||
|
||||
return {
|
||||
clusterAId,
|
||||
clusterBId,
|
||||
clusterAName,
|
||||
clusterBName,
|
||||
suggestedTitle: bestIdea.title,
|
||||
suggestedContent: bestIdea.description,
|
||||
justification: bestIdea.justification
|
||||
suggestedTitle: idea.title,
|
||||
suggestedContent: idea.description,
|
||||
justification: idea.justification || '',
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating bridge suggestion:', error)
|
||||
@@ -351,26 +529,93 @@ Return ONLY the JSON, no other text.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bridge suggestions for a user.
|
||||
* Get bridge suggestions — purge ornamental leftovers from older algorithms.
|
||||
* Resolve cluster display names from NoteCluster (fixes frozen "Cluster 15" in BridgeSuggestion rows).
|
||||
*/
|
||||
async getBridgeSuggestions(userId: string, includeDismissed: boolean = false): Promise<BridgeSuggestion[]> {
|
||||
const nameById = await clusteringService.getClusterNameMap(userId)
|
||||
|
||||
const suggestions = await prisma.bridgeSuggestion.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(includeDismissed ? {} : { isDismissed: false })
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 40,
|
||||
})
|
||||
|
||||
return suggestions.map(s => ({
|
||||
clusterAId: s.clusterAId,
|
||||
clusterBId: s.clusterBId,
|
||||
clusterAName: s.clusterAName,
|
||||
clusterBName: s.clusterBName,
|
||||
suggestedTitle: s.suggestedTitle,
|
||||
suggestedContent: s.suggestedContent,
|
||||
justification: s.justification
|
||||
}))
|
||||
const mapped = suggestions.map(s => {
|
||||
const clusterAName = nameById.get(s.clusterAId) || clusteringService.displayName(s.clusterAName, s.clusterAId)
|
||||
const clusterBName = nameById.get(s.clusterBId) || clusteringService.displayName(s.clusterBName, s.clusterBId)
|
||||
return {
|
||||
clusterAId: s.clusterAId,
|
||||
clusterBId: s.clusterBId,
|
||||
clusterAName,
|
||||
clusterBName,
|
||||
suggestedTitle: s.suggestedTitle,
|
||||
suggestedContent: s.suggestedContent,
|
||||
justification: s.justification
|
||||
}
|
||||
})
|
||||
|
||||
// Persist corrected names when suggestions still store placeholders
|
||||
const nameFixes = suggestions.filter(s => {
|
||||
const resolvedA = nameById.get(s.clusterAId)
|
||||
const resolvedB = nameById.get(s.clusterBId)
|
||||
return (
|
||||
(resolvedA && clusteringService.isPlaceholderClusterName(s.clusterAName)) ||
|
||||
(resolvedB && clusteringService.isPlaceholderClusterName(s.clusterBName))
|
||||
)
|
||||
})
|
||||
if (nameFixes.length > 0) {
|
||||
await Promise.all(
|
||||
nameFixes.map(s =>
|
||||
prisma.bridgeSuggestion.updateMany({
|
||||
where: { userId, clusterAId: s.clusterAId, clusterBId: s.clusterBId },
|
||||
data: {
|
||||
clusterAName: nameById.get(s.clusterAId) || clusteringService.displayName(s.clusterAName, s.clusterAId),
|
||||
clusterBName: nameById.get(s.clusterBId) || clusteringService.displayName(s.clusterBName, s.clusterBId),
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const keep: BridgeSuggestion[] = []
|
||||
const dropIds: Array<{ clusterAId: number; clusterBId: number }> = []
|
||||
|
||||
for (const s of mapped) {
|
||||
if (this.looksLikeForcedMetaphor(s)) {
|
||||
dropIds.push({ clusterAId: s.clusterAId, clusterBId: s.clusterBId })
|
||||
continue
|
||||
}
|
||||
const [summaryA, summaryB, ca, cb] = await Promise.all([
|
||||
this.getClusterSummary(s.clusterAId, userId),
|
||||
this.getClusterSummary(s.clusterBId, userId),
|
||||
this.getClusterCentroid(s.clusterAId, userId),
|
||||
this.getClusterCentroid(s.clusterBId, userId),
|
||||
])
|
||||
if (!ca || !cb) {
|
||||
dropIds.push({ clusterAId: s.clusterAId, clusterBId: s.clusterBId })
|
||||
continue
|
||||
}
|
||||
const sim = this.cosineSimilarity(ca, cb)
|
||||
if (sim < 0.45 || sim > 0.58 || !this.hasLexicalOverlap(summaryA, summaryB)) {
|
||||
dropIds.push({ clusterAId: s.clusterAId, clusterBId: s.clusterBId })
|
||||
continue
|
||||
}
|
||||
keep.push(s)
|
||||
}
|
||||
|
||||
if (dropIds.length > 0) {
|
||||
await Promise.all(
|
||||
dropIds.map(({ clusterAId, clusterBId }) =>
|
||||
prisma.bridgeSuggestion.deleteMany({ where: { userId, clusterAId, clusterBId } })
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return keep.slice(0, 5)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,11 +623,7 @@ Return ONLY the JSON, no other text.`
|
||||
*/
|
||||
async dismissSuggestion(userId: string, clusterAId: number, clusterBId: number): Promise<void> {
|
||||
await prisma.bridgeSuggestion.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
clusterAId,
|
||||
clusterBId
|
||||
},
|
||||
where: { userId, clusterAId, clusterBId },
|
||||
data: { isDismissed: true }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
buildMemoryEchoInsightPrompt,
|
||||
getMemoryEchoInsightFallback,
|
||||
} from '@/lib/ai/memory-echo-i18n'
|
||||
import { withAiQuota } from '@/lib/ai-quota'
|
||||
import {
|
||||
SEMANTIC_SIMILARITY_FLOOR_CLIP,
|
||||
SEMANTIC_SIMILARITY_FLOOR_DEMO,
|
||||
@@ -463,14 +464,20 @@ export class MemoryEchoService {
|
||||
return null // All connections already shown
|
||||
}
|
||||
|
||||
// Generate AI insight in user's language
|
||||
// Generate AI insight in user's language (quota: reformulate, with rollback on failure)
|
||||
const userLanguage = await detectUserLanguage()
|
||||
const insightText = await this.generateInsight(
|
||||
newConnection.note1.title,
|
||||
newConnection.note1.content,
|
||||
newConnection.note2.title,
|
||||
newConnection.note2.content || '',
|
||||
userLanguage,
|
||||
const insightText = await withAiQuota(
|
||||
userId,
|
||||
'reformulate',
|
||||
() =>
|
||||
this.generateInsight(
|
||||
newConnection.note1.title,
|
||||
newConnection.note1.content,
|
||||
newConnection.note2.title,
|
||||
newConnection.note2.content || '',
|
||||
userLanguage,
|
||||
),
|
||||
{ lane: 'chat' },
|
||||
)
|
||||
|
||||
// Store insight in database
|
||||
|
||||
Reference in New Issue
Block a user