mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
- Security: REVOKE ALL FROM PUBLIC + GRANT EXECUTE (authenticated,
service_role) on the match RPCs so anon can't read a KB by supplying
an account_id (mirrors migrations 018/025).
- Ingest: store chunks even when embedding fails (insert first, rethrow
after) so a doc stays lexically searchable; the "lexical still works"
warning is now truthful.
- Retrieval: short-circuit on an empty KB (cheap COUNT) to avoid a paid
query embedding + 2 RPCs on every draft/auto-reply.
- Migration: HNSW instead of IVFFlat (robust on small/growing tables);
language-neutral 'simple' FTS config (was 'english'); semantic RPC
takes text and casts ::vector(1536) to remove PostgREST bind ambiguity.
- Embeddings key: distinguish corrupt-from-absent (loadEmbeddingsKey
returns {key,corrupt}); log on decrypt failure; reindex now reports
failure instead of false success; POST/PATCH warn on keyword-only save.
- embedTexts: fail loud when the provider omits result indices (was
silently misaligning chunks with vectors).
Tests: +3 (empty-KB guard, embed-failure-still-indexes, missing-index).
587 pass; typecheck/lint/build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
133 lines
4.5 KiB
TypeScript
133 lines
4.5 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import {
|
|
getCurrentAccount,
|
|
requireRole,
|
|
toErrorResponse,
|
|
} from '@/lib/auth/account'
|
|
import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit'
|
|
import { loadEmbeddingsKey } from '@/lib/ai/config'
|
|
import { ingestDocument } from '@/lib/ai/knowledge'
|
|
import { AiError } from '@/lib/ai/types'
|
|
|
|
type Params = { params: Promise<{ id: string }> }
|
|
|
|
/**
|
|
* GET /api/ai/knowledge/[id] — full document (any member).
|
|
*/
|
|
export async function GET(_request: Request, { params }: Params) {
|
|
try {
|
|
const { supabase, accountId } = await getCurrentAccount()
|
|
const { id } = await params
|
|
const { data, error } = await supabase
|
|
.from('ai_knowledge_documents')
|
|
.select('id, title, content, updated_at')
|
|
.eq('account_id', accountId)
|
|
.eq('id', id)
|
|
.maybeSingle()
|
|
if (error) {
|
|
console.error('[ai/knowledge/[id] GET] error:', error)
|
|
return NextResponse.json({ error: 'Failed to load document' }, { status: 500 })
|
|
}
|
|
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
return NextResponse.json(data)
|
|
} catch (err) {
|
|
return toErrorResponse(err)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PATCH /api/ai/knowledge/[id] (admin+) — update title/content and
|
|
* re-index when the content changed.
|
|
*/
|
|
export async function PATCH(request: Request, { params }: Params) {
|
|
try {
|
|
const { supabase, accountId, userId } = await requireRole('admin')
|
|
const limit = checkRateLimit(`ai-kb:${userId}`, RATE_LIMITS.adminAction)
|
|
if (!limit.success) return rateLimitResponse(limit)
|
|
|
|
const { id } = await params
|
|
const body = await request.json().catch(() => null)
|
|
const title = typeof body?.title === 'string' ? body.title.trim() : undefined
|
|
const content = typeof body?.content === 'string' ? body.content.trim() : undefined
|
|
if (title === undefined && content === undefined) {
|
|
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
|
|
}
|
|
if (title !== undefined && !title) {
|
|
return NextResponse.json({ error: 'title cannot be empty' }, { status: 400 })
|
|
}
|
|
if (content !== undefined && !content) {
|
|
return NextResponse.json({ error: 'content cannot be empty' }, { status: 400 })
|
|
}
|
|
|
|
const update: Record<string, string> = {}
|
|
if (title !== undefined) update.title = title
|
|
if (content !== undefined) update.content = content
|
|
|
|
const { data: updated, error } = await supabase
|
|
.from('ai_knowledge_documents')
|
|
.update(update)
|
|
.eq('account_id', accountId)
|
|
.eq('id', id)
|
|
.select('id')
|
|
.maybeSingle()
|
|
if (error) {
|
|
console.error('[ai/knowledge/[id] PATCH] error:', error)
|
|
return NextResponse.json({ error: 'Failed to update document' }, { status: 500 })
|
|
}
|
|
if (!updated) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
|
|
if (content !== undefined) {
|
|
const { key: embeddingsApiKey, corrupt } = await loadEmbeddingsKey(
|
|
supabase,
|
|
accountId,
|
|
)
|
|
try {
|
|
await ingestDocument(supabase, accountId, { embeddingsApiKey }, id, content)
|
|
} catch (err) {
|
|
const message = err instanceof AiError ? err.message : 'indexing failed'
|
|
console.error('[ai/knowledge/[id] PATCH] ingest error:', err)
|
|
return NextResponse.json(
|
|
{
|
|
success: true,
|
|
warning: `Updated, but semantic indexing failed (${message}). Lexical search still works; use Reindex to retry.`,
|
|
},
|
|
{ status: 200 },
|
|
)
|
|
}
|
|
if (corrupt) {
|
|
return NextResponse.json({
|
|
success: true,
|
|
warning:
|
|
'Updated with keyword search only — your embeddings key could not be decrypted (check ENCRYPTION_KEY, then re-enter the key).',
|
|
})
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({ success: true })
|
|
} catch (err) {
|
|
return toErrorResponse(err)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* DELETE /api/ai/knowledge/[id] (admin+) — chunks cascade.
|
|
*/
|
|
export async function DELETE(_request: Request, { params }: Params) {
|
|
try {
|
|
const { supabase, accountId } = await requireRole('admin')
|
|
const { id } = await params
|
|
const { error } = await supabase
|
|
.from('ai_knowledge_documents')
|
|
.delete()
|
|
.eq('account_id', accountId)
|
|
.eq('id', id)
|
|
if (error) {
|
|
console.error('[ai/knowledge/[id] DELETE] error:', error)
|
|
return NextResponse.json({ error: 'Failed to delete document' }, { status: 500 })
|
|
}
|
|
return NextResponse.json({ success: true })
|
|
} catch (err) {
|
|
return toErrorResponse(err)
|
|
}
|
|
}
|