wacrm/supabase/migrations/030_ai_knowledge.sql
Arnas Donauskas 020dc2ae11 fix(ai-kb): address code-review findings
- 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>
2026-07-02 17:20:38 +03:00

205 lines
9.5 KiB
PL/PgSQL

-- ============================================================
-- 030_ai_knowledge.sql — AI knowledge base (RAG grounding)
--
-- Gives the AI assistant (migration 029) an account-owned knowledge
-- base — FAQ / policy / product text — that it retrieves into every
-- draft and auto-reply, so it can answer business-specific questions
-- instead of handing off.
--
-- Hybrid retrieval:
-- - Lexical: a generated `fts` tsvector on each chunk, ranked with
-- ts_rank. Works for every account with no extra credentials.
-- - Semantic: an optional pgvector `embedding` per chunk (OpenAI
-- text-embedding-3-small, 1536 dims), populated only when the
-- account configures an embeddings key. Anthropic-only accounts
-- (Anthropic has no embeddings API) keep the lexical path with
-- zero extra setup.
--
-- pgvector: `CREATE EXTENSION IF NOT EXISTS vector` works on a stock
-- Postgres. On hosted Supabase the extension usually lives in the
-- `extensions` schema — if your project pins that, run
-- `create extension if not exists vector with schema extensions;`
-- once, then this file is a no-op for the extension.
--
-- RLS: settings-class, mirroring `ai_configs` / `whatsapp_config` —
-- any member may read the knowledge base; only admin+ may change it.
-- The retrieval RPCs and the ingest path run under the service-role
-- client (the auto-reply bot has no auth.uid()), so RLS guards
-- dashboard reads.
--
-- Idempotent — safe to run multiple times.
-- ============================================================
CREATE EXTENSION IF NOT EXISTS vector;
-- Optional embeddings key (OpenAI-compatible). When set, the KB is
-- embedded and semantic search turns on. Stored AES-256-GCM-encrypted,
-- same as ai_configs.api_key.
ALTER TABLE ai_configs
ADD COLUMN IF NOT EXISTS embeddings_api_key text;
-- ============================================================
-- Documents — one row per KB entry the user pastes (title + body).
-- ============================================================
CREATE TABLE IF NOT EXISTS ai_knowledge_documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
title text NOT NULL,
content text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ai_knowledge_documents_account_id_idx
ON ai_knowledge_documents (account_id);
ALTER TABLE ai_knowledge_documents ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS ai_knowledge_documents_select ON ai_knowledge_documents;
CREATE POLICY ai_knowledge_documents_select ON ai_knowledge_documents FOR SELECT
USING (is_account_member(account_id));
DROP POLICY IF EXISTS ai_knowledge_documents_insert ON ai_knowledge_documents;
CREATE POLICY ai_knowledge_documents_insert ON ai_knowledge_documents FOR INSERT
WITH CHECK (is_account_member(account_id, 'admin'));
DROP POLICY IF EXISTS ai_knowledge_documents_update ON ai_knowledge_documents;
CREATE POLICY ai_knowledge_documents_update ON ai_knowledge_documents FOR UPDATE
USING (is_account_member(account_id, 'admin'));
DROP POLICY IF EXISTS ai_knowledge_documents_delete ON ai_knowledge_documents;
CREATE POLICY ai_knowledge_documents_delete ON ai_knowledge_documents FOR DELETE
USING (is_account_member(account_id, 'admin'));
CREATE OR REPLACE FUNCTION public.update_ai_knowledge_documents_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS ai_knowledge_documents_updated_at ON ai_knowledge_documents;
CREATE TRIGGER ai_knowledge_documents_updated_at
BEFORE UPDATE ON ai_knowledge_documents
FOR EACH ROW
EXECUTE FUNCTION public.update_ai_knowledge_documents_updated_at();
-- ============================================================
-- Chunks — retrieval units. `account_id` is denormalized off the
-- document so the match RPCs and RLS filter without a join.
-- ============================================================
CREATE TABLE IF NOT EXISTS ai_knowledge_chunks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
document_id uuid NOT NULL REFERENCES ai_knowledge_documents(id) ON DELETE CASCADE,
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
chunk_index integer NOT NULL DEFAULT 0,
content text NOT NULL,
-- Language-neutral FTS config: wacrm is used in many languages
-- (its markets include BR / LATAM / IN), and this lexical path is the
-- fallback for accounts without an embeddings key. `'simple'` tokenizes
-- + lowercases without English-only stemming/stopwords, so it degrades
-- gracefully in any language. (Per-account language config is a
-- follow-up; accounts wanting paraphrase/morphology matching add an
-- embeddings key for the semantic path.)
fts tsvector GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED,
embedding vector(1536),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ai_knowledge_chunks_account_id_idx
ON ai_knowledge_chunks (account_id);
CREATE INDEX IF NOT EXISTS ai_knowledge_chunks_document_id_idx
ON ai_knowledge_chunks (document_id);
CREATE INDEX IF NOT EXISTS ai_knowledge_chunks_fts_idx
ON ai_knowledge_chunks USING gin (fts);
-- Cosine-distance ANN index for the semantic path. Rows with a NULL
-- embedding (lexical-only accounts) are simply absent from it.
--
-- HNSW (not IVFFlat): per-account knowledge bases start empty and grow
-- incrementally, and IVFFlat must be trained on existing rows — built
-- against an empty/tiny table its centroids are meaningless and recall
-- is poor until it's large and REINDEXed. HNSW needs no training and is
-- accurate from the first row.
CREATE INDEX IF NOT EXISTS ai_knowledge_chunks_embedding_idx
ON ai_knowledge_chunks USING hnsw (embedding vector_cosine_ops);
ALTER TABLE ai_knowledge_chunks ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS ai_knowledge_chunks_select ON ai_knowledge_chunks;
CREATE POLICY ai_knowledge_chunks_select ON ai_knowledge_chunks FOR SELECT
USING (is_account_member(account_id));
DROP POLICY IF EXISTS ai_knowledge_chunks_insert ON ai_knowledge_chunks;
CREATE POLICY ai_knowledge_chunks_insert ON ai_knowledge_chunks FOR INSERT
WITH CHECK (is_account_member(account_id, 'admin'));
DROP POLICY IF EXISTS ai_knowledge_chunks_update ON ai_knowledge_chunks;
CREATE POLICY ai_knowledge_chunks_update ON ai_knowledge_chunks FOR UPDATE
USING (is_account_member(account_id, 'admin'));
DROP POLICY IF EXISTS ai_knowledge_chunks_delete ON ai_knowledge_chunks;
CREATE POLICY ai_knowledge_chunks_delete ON ai_knowledge_chunks FOR DELETE
USING (is_account_member(account_id, 'admin'));
-- ============================================================
-- Retrieval RPCs. Both SECURITY DEFINER and hard-scoped to the passed
-- account_id so the service-role caller can only ever read one
-- account's chunks.
-- ============================================================
-- Lexical: full-text rank. `plainto_tsquery` turns a raw customer
-- message into a query safely (no operator injection). Uses the same
-- language-neutral `'simple'` config as the stored `fts` column.
CREATE OR REPLACE FUNCTION public.match_ai_knowledge_fts(
p_account_id uuid,
p_query text,
p_match_count integer
)
RETURNS TABLE (id uuid, content text, rank real) AS $$
SELECT c.id,
c.content,
ts_rank(c.fts, plainto_tsquery('simple', p_query)) AS rank
FROM ai_knowledge_chunks c
WHERE c.account_id = p_account_id
AND c.fts @@ plainto_tsquery('simple', p_query)
ORDER BY rank DESC
LIMIT GREATEST(p_match_count, 0);
$$ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public;
-- Semantic: cosine distance against the query embedding. Only rows
-- that actually have an embedding participate.
--
-- `p_query_embedding` is declared `text` (not `vector`) and cast inside:
-- the caller sends the canonical pgvector literal `[0.1,0.2,...]` as a
-- plain string, so there's no ambiguity in how PostgREST binds a JSON
-- value to a `vector` parameter. Casting a literal to a constant vector
-- still lets the HNSW index serve the `<=>` order-by.
CREATE OR REPLACE FUNCTION public.match_ai_knowledge_semantic(
p_account_id uuid,
p_query_embedding text,
p_match_count integer
)
RETURNS TABLE (id uuid, content text, distance real) AS $$
SELECT c.id,
c.content,
(c.embedding <=> p_query_embedding::vector(1536)) AS distance
FROM ai_knowledge_chunks c
WHERE c.account_id = p_account_id
AND c.embedding IS NOT NULL
ORDER BY c.embedding <=> p_query_embedding::vector(1536)
LIMIT GREATEST(p_match_count, 0);
$$ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public;
-- Lock down EXECUTE (mirrors migrations 018 / 025). These are
-- SECURITY DEFINER and would otherwise default to PUBLIC — i.e. the
-- anon role — which, since the function bypasses RLS and only gates on
-- the passed account_id, would let an unauthenticated caller read any
-- account's knowledge base. The draft path calls them as `authenticated`
-- and the auto-reply bot as `service_role`.
REVOKE ALL ON FUNCTION public.match_ai_knowledge_fts(uuid, text, integer) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.match_ai_knowledge_fts(uuid, text, integer) TO authenticated, service_role;
REVOKE ALL ON FUNCTION public.match_ai_knowledge_semantic(uuid, text, integer) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.match_ai_knowledge_semantic(uuid, text, integer) TO authenticated, service_role;