mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +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>
205 lines
9.5 KiB
PL/PgSQL
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;
|