Files
Momento/memento-note/app/api/brainstorm/route.ts
Antigravity 8c7ca69640
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
fix: brainstorm infinite loop, ghost cursor, embedding ::vector cast, semantic search, billing stats, usage meter accordion
- Fix useBrainstormSocket: stable guestId via useRef, remove setState in cleanup
- Fix GhostCursor: direct DOM manipulation via refs, no useState re-renders
- Fix all SQL embedding queries: add ::vector cast on text columns
- Fix embedding truncation to 15000 chars (under 8192 token limit)
- Fix NoteEmbedding INSERT: remove non-existent updatedAt column
- Fix billing page: show all quota stats in grid instead of single metric
- Fix usage meter: accordion expand/collapse, per-feature detail
- Fix semantic search: rebuild 103 note embeddings, ::vector cast on vectorSearch
- Fix brainstorm expand/manual-idea/create: ::vector cast on embedding SQL
2026-05-16 18:50:34 +00:00

429 lines
16 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { z } from 'zod'
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'
import { getSystemConfig } from '@/lib/config'
import { embeddingService } from '@/lib/ai/services/embedding.service'
import {
checkEntitlementOrThrow,
QuotaExceededError,
incrementUsageAsync,
} from '@/lib/entitlements'
import { logActivity, captureSnapshot } from '@/lib/brainstorm-collab'
const waveSchema = z.object({
seedIdea: z.string().min(1, 'Seed idea is required'),
sourceNoteId: z.string().optional(),
contextNoteIds: z.array(z.string()).optional(),
locale: z.string().optional(),
})
interface ClassifiedNote {
id: string
title: string
summary: string
category: 'SUPPORT' | 'TENSION' | 'EXTENSION'
}
async function autoContextSearch(
userId: string,
seedIdea: string,
userNoteIds?: string[]
): Promise<ClassifiedNote[]> {
let candidateIds: string[] = []
if (userNoteIds && userNoteIds.length > 0) {
candidateIds = userNoteIds
} else {
try {
const embedding = await embeddingService.generateEmbedding(seedIdea)
const vectorStr = embeddingService.toVectorString(embedding.embedding)
const results = await prisma.$queryRawUnsafe(
`SELECT n.id
FROM "NoteEmbedding" e
JOIN "Note" n ON n.id = e."noteId"
WHERE n."userId" = $1 AND n."trashedAt" IS NULL
ORDER BY e.embedding::vector <=> $2::vector
LIMIT 8`,
userId, vectorStr
) as any[]
candidateIds = results.map((r: any) => r.id)
} catch {
return []
}
}
if (candidateIds.length === 0) return []
const notes = await prisma.note.findMany({
where: { id: { in: candidateIds }, userId, trashedAt: null },
select: { id: true, title: true, content: true },
})
if (notes.length === 0) return []
const notesForLLM = notes.map(n => ({
id: n.id,
title: n.title || 'Untitled',
snippet: (n.content || '').slice(0, 300),
}))
const classifyPrompt = `Given the seed idea: "${seedIdea}"
Classify each note as SUPPORT (confirms/reinforces the seed), TENSION (contradicts/questions the seed), or EXTENSION (extends the seed into an adjacent domain).
Notes:
${notesForLLM.map(n => `[${n.id}] "${n.title}": ${n.snippet}`).join('\n')}
Respond ONLY with a valid JSON array of objects:
{ "noteId": string, "category": "SUPPORT" | "TENSION" | "EXTENSION" }`
try {
const config = await getSystemConfig()
const { result: raw } = await runLaneWithBillingUser(
'tags',
config,
userId,
(provider) => provider.generateText(classifyPrompt),
)
const cleaned = raw.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim()
const classifications: { noteId: string; category: 'SUPPORT' | 'TENSION' | 'EXTENSION' }[] = JSON.parse(cleaned)
return notes.map(n => {
const cls = classifications.find(c => c.noteId === n.id)
return {
id: n.id,
title: n.title || 'Untitled',
summary: (n.content || '').slice(0, 200),
category: cls?.category || 'EXTENSION',
}
})
} catch {
return notes.map(n => ({
id: n.id,
title: n.title || 'Untitled',
summary: (n.content || '').slice(0, 200),
category: 'EXTENSION' as const,
}))
}
}
function buildPromptV2(seedIdea: string, classifiedNotes: ClassifiedNote[], locale?: string): string {
const supportNotes = classifiedNotes.filter(n => n.category === 'SUPPORT')
const tensionNotes = classifiedNotes.filter(n => n.category === 'TENSION')
const extensionNotes = classifiedNotes.filter(n => n.category === 'EXTENSION')
let notesSection = ''
if (classifiedNotes.length > 0) {
notesSection = `\nUSER'S EXISTING NOTES (classified by relationship to the seed):\n`
if (supportNotes.length > 0) {
notesSection += `\nSUPPORTING NOTES (confirm/reinforce the seed):\n`
notesSection += supportNotes.map(n => `- [ID: ${n.id}] "${n.title}": ${n.summary}`).join('\n')
}
if (tensionNotes.length > 0) {
notesSection += `\nTENSION NOTES (contradict or question the seed):\n`
notesSection += tensionNotes.map(n => `- [ID: ${n.id}] "${n.title}": ${n.summary}`).join('\n')
}
if (extensionNotes.length > 0) {
notesSection += `\nEXTENSION NOTES (extend the seed into adjacent domains):\n`
notesSection += extensionNotes.map(n => `- [ID: ${n.id}] "${n.title}": ${n.summary}`).join('\n')
}
}
return `You are a creative brainstorming assistant with access to the user's personal knowledge base. Your job is to generate ideas that DELIBERATELY CROSS the seed concept with existing notes — creating productive tension, not just variations.
USER'S SEED IDEA: ${seedIdea}
${notesSection}
GENERATION RULES:
WAVE 1 — VARIATIONS (3 ideas):
- At least 1 idea must BUILD ON a SUPPORTING note
- At least 1 idea must RESPOND TO a TENSION note (resolve or embrace the contradiction)
WAVE 2 — ANALOGIES (3 ideas):
- At least 1 idea must FUSE a concept from an EXTENSION note with the seed
- At least 1 idea must be a PATTERN TRANSPOSITION from any note
WAVE 3 — DISRUPTIONS (3 ideas):
- At least 1 idea must INVERT an assumption found in a SUPPORTING note
- At least 1 idea must SYNTHESIZE two notes that appear contradictory
RESPOND ONLY with a valid JSON array of 9 objects:
{
"wave": number (1, 2, or 3),
"title": string (short, 2-6 words),
"description": string (1-2 sentences, specific and actionable),
"connectionToSeed": string (how it relates to the seed),
"noveltyScore": number (1-10),
"noteRefs": [
{
"noteId": string (must match an ID provided above, or null if genuinely no note connects),
"relation": "derived_from" | "opposes" | "extends" | "synthesizes" | "transposes",
"explanation": string (natural language, e.g. "Built on your exploration of occupancy-based scheduling")
}
]
}
CRITICAL: Each idea MUST have at least 1 noteRef. Only use null noteId if genuinely no note connects to that idea.
LANGUAGE: You MUST write ALL titles, descriptions, connectionToSeed, and explanation fields in ${locale === 'fr' ? 'French' : locale === 'es' ? 'Spanish' : locale === 'de' ? 'German' : locale === 'it' ? 'Italian' : locale === 'pt' ? 'Portuguese' : locale === 'nl' ? 'Dutch' : locale === 'ru' ? 'Russian' : locale === 'zh' ? 'Chinese' : locale === 'ja' ? 'Japanese' : locale === 'ko' ? 'Korean' : locale === 'ar' ? 'Arabic' : locale === 'fa' ? 'Farsi' : locale === 'hi' ? 'Hindi' : locale === 'pl' ? 'Polish' : 'the same language as the seed idea'}.`
}
function buildFallbackIdeas(classifiedNotes: ClassifiedNote[]): any[] {
const notesById = Object.fromEntries(classifiedNotes.map(n => [n.id, n]))
const support = classifiedNotes.filter(n => n.category === 'SUPPORT')
const tension = classifiedNotes.filter(n => n.category === 'TENSION')
const extension = classifiedNotes.filter(n => n.category === 'EXTENSION')
const pickNote = (list: ClassifiedNote[], idx: number) => {
if (list.length === 0) return { noteId: null, relation: 'extends' as const, explanation: 'Purely generative idea, no direct note link' }
const n = list[idx % list.length]
return { noteId: n.id, relation: 'extends' as const, explanation: `Inspired by your note "${n.title}"` }
}
return [
{ wave: 1, title: 'Variation A', description: 'A direct variation of the seed idea.', connectionToSeed: 'Direct extension', noveltyScore: 3, noteRefs: [pickNote(support, 0)] },
{ wave: 1, title: 'Variation B', description: 'Another angle on the seed idea.', connectionToSeed: 'Reformulation', noveltyScore: 4, noteRefs: [pickNote(tension, 0)] },
{ wave: 1, title: 'Variation C', description: 'A sub-aspect of the seed.', connectionToSeed: 'Sub-component', noveltyScore: 5, noteRefs: [pickNote(support, 1)] },
{ wave: 2, title: 'Analogy A', description: 'Inspired by biological systems.', connectionToSeed: 'Cross-domain analogy', noveltyScore: 6, noteRefs: [pickNote(extension, 0)] },
{ wave: 2, title: 'Analogy B', description: 'Drawn from technology patterns.', connectionToSeed: 'Tech parallel', noveltyScore: 7, noteRefs: [pickNote(extension, 1)] },
{ wave: 2, title: 'Analogy C', description: 'Based on natural phenomena.', connectionToSeed: 'Nature metaphor', noveltyScore: 6, noteRefs: [pickNote(classifiedNotes, 0)] },
{ wave: 3, title: 'Disruption A', description: 'What if we inverted the core assumption?', connectionToSeed: 'Inversion', noveltyScore: 9, noteRefs: [{ ...(pickNote(support, 0)), relation: 'opposes' as const }] },
{ wave: 3, title: 'Disruption B', description: 'A provocative reframing.', connectionToSeed: 'Challenge premise', noveltyScore: 8, noteRefs: [{ ...(pickNote(tension, 0)), relation: 'synthesizes' as const }] },
{ wave: 3, title: 'Disruption C', description: 'Removing a key constraint entirely.', connectionToSeed: 'Constraint removal', noveltyScore: 10, noteRefs: [pickNote(extension, 2)] },
]
}
export async function POST(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
try {
const body = await request.json()
const { seedIdea, sourceNoteId, contextNoteIds, locale } = waveSchema.parse(body)
// Story 3.5: per-provider BYOK bypass
const config = await getSystemConfig()
const { usedByok: willUseByok } = await willUseByokForLane('tags', config, userId)
if (!willUseByok) {
await checkEntitlementOrThrow(userId, 'brainstorm_create')
}
const classifiedNotes = await autoContextSearch(userId, seedIdea, contextNoteIds)
const prompt = buildPromptV2(seedIdea, classifiedNotes, locale)
const { result: llmResponse, usedByok } = await runLaneWithBillingUser(
'tags',
config,
userId,
(provider) => provider.generateText(prompt),
)
if (!usedByok) incrementUsageAsync(userId, 'brainstorm_create')
let ideas: any[]
try {
const cleaned = llmResponse.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim()
ideas = JSON.parse(cleaned)
if (!Array.isArray(ideas)) throw new Error('Not an array')
} catch {
ideas = buildFallbackIdeas(classifiedNotes)
}
const validNoteIds = new Set(classifiedNotes.map(n => n.id))
const brainstormSession = await prisma.brainstormSession.create({
data: {
seedIdea,
sourceNoteId: sourceNoteId || null,
contextNoteIds: contextNoteIds ? JSON.stringify(contextNoteIds) : null,
liveblocksRoomId: `brainstorm-session`,
userId,
},
})
await prisma.brainstormParticipant.create({
data: {
sessionId: brainstormSession.id,
userId,
role: 'host',
},
})
await logActivity(brainstormSession.id, 'wave_generated', userId, { count: ideas.length })
const createdIdeas = []
for (let idx = 0; idx < ideas.length; idx++) {
const idea = ideas[idx]
const angle = (idx % 3) * (2 * Math.PI / 3) + (idea.wave - 1) * 0.5
const radius = idea.wave * 150
const created = await prisma.brainstormIdea.create({
data: {
sessionId: brainstormSession.id,
waveNumber: idea.wave || Math.floor(idx / 3) + 1,
title: idea.title || `Idea ${idx + 1}`,
description: idea.description || '',
connectionToSeed: idea.connectionToSeed || null,
noveltyScore: idea.noveltyScore || null,
relatedNoteIds: JSON.stringify(
(idea.noteRefs || []).map((r: any) => r.noteId).filter(Boolean)
),
positionX: Math.cos(angle) * radius,
positionY: Math.sin(angle) * radius,
},
})
if (idea.noteRefs && Array.isArray(idea.noteRefs)) {
for (const ref of idea.noteRefs) {
const noteId = ref.noteId && validNoteIds.has(ref.noteId) ? ref.noteId : null
await prisma.brainstormNoteRef.create({
data: {
ideaId: created.id,
noteId,
relation: ref.relation || 'extends',
explanation: ref.explanation || '',
},
})
}
}
createdIdeas.push(created)
}
const fullSession = await prisma.brainstormSession.findUnique({
where: { id: brainstormSession.id },
include: {
ideas: {
orderBy: [{ waveNumber: 'asc' }, { createdAt: 'asc' }],
include: {
noteRefs: {
include: {
note: { select: { id: true, title: true } },
},
},
},
},
},
})
const cIds = [...new Set((fullSession?.ideas || []).map((i: any) => i.createdBy).filter(Boolean))]
if (cIds.length > 0) {
const crs = await prisma.user.findMany({ where: { id: { in: cIds } }, select: { id: true, name: true, image: true } })
const cm = new Map(crs.map((c: any) => [c.id, c]))
for (const idea of fullSession?.ideas || []) { (idea as any).creator = (idea as any).createdBy ? cm.get((idea as any).createdBy) || null : null }
}
await captureSnapshot(brainstormSession.id, `Initial wave: ${brainstormSession.seedIdea.substring(0, 30)}`).catch(() => {})
return NextResponse.json({
success: true,
data: fullSession,
contextSummary: {
support: classifiedNotes.filter(n => n.category === 'SUPPORT').length,
tension: classifiedNotes.filter(n => n.category === 'TENSION').length,
extension: classifiedNotes.filter(n => n.category === 'EXTENSION').length,
},
}, { status: 201 })
} catch (error: any) {
if (error instanceof QuotaExceededError) {
return NextResponse.json(error.toJSON(), { status: 402 })
}
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.issues }, { status: 400 })
}
console.error('Error creating brainstorm:', error)
return NextResponse.json(
{ error: error.message || 'Failed to create brainstorm' },
{ status: 500 }
)
}
}
export async function GET(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
try {
const ownedSessions = await prisma.brainstormSession.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
include: {
_count: { select: { ideas: true } },
ideas: {
where: { status: 'active' },
select: { id: true },
},
},
})
const owned = ownedSessions.map(s => ({
id: s.id,
seedIdea: s.seedIdea,
sourceNoteId: s.sourceNoteId,
exportedNoteId: s.exportedNoteId,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
totalIdeas: s._count.ideas,
activeIdeas: s.ideas.length,
_owned: true,
}))
const ownedIds = new Set(owned.map(s => s.id))
let shared: any[] = []
try {
const acceptedShareRows = await prisma.brainstormShare.findMany({
where: { userId, status: 'accepted' },
include: {
session: {
select: {
id: true,
seedIdea: true,
sourceNoteId: true,
exportedNoteId: true,
createdAt: true,
updatedAt: true,
_count: { select: { ideas: true } },
ideas: { where: { status: 'active' }, select: { id: true } },
},
},
},
})
shared = acceptedShareRows
.filter(s => s.session != null && !ownedIds.has(s.session.id))
.map(s => ({
id: s.session.id,
seedIdea: s.session.seedIdea,
sourceNoteId: s.session.sourceNoteId,
exportedNoteId: s.session.exportedNoteId,
createdAt: s.session.createdAt,
updatedAt: s.session.updatedAt,
totalIdeas: s.session._count.ideas,
activeIdeas: s.session.ideas.length,
_owned: false,
}))
} catch (shareError) {
console.error('Error fetching shared brainstorms (non-fatal):', shareError)
}
return NextResponse.json({
success: true,
data: [...owned, ...shared],
})
} catch (error) {
console.error('Error fetching brainstorms:', error)
return NextResponse.json(
{ error: 'Failed to fetch brainstorms' },
{ status: 500 }
)
}
}