mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Grounds the AI assistant (0.5.0) in the account's own content so it can answer instead of handing off. - Hybrid retrieval: Postgres full-text search for every account; optional semantic search (pgvector + OpenAI text-embedding-3-small) when an embeddings key is set. Semantic-primary, topped up with FTS. - src/lib/ai: chunk, embeddings, knowledge (ingest + retrieve), query; buildSystemPrompt gains a knowledge block; config carries an (optional) embeddings key. - Wired into /api/ai/draft and the auto-reply bot (best-effort — never blocks a reply). - KB API (/api/ai/knowledge CRUD + /reindex) and a Settings → AI Assistant knowledge-base manager + embeddings-key field. - Migration 030: pgvector, ai_knowledge_documents, ai_knowledge_chunks (fts + embedding), match RPCs, ai_configs.embeddings_api_key. Migration required: supabase/migrations/030_ai_knowledge.sql. Stacked on feat/ai-reply-assistant (#329). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
175 lines
7.5 KiB
PL/PgSQL
175 lines
7.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,
|
|
fts tsvector GENERATED ALWAYS AS (to_tsvector('english', 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.
|
|
CREATE INDEX IF NOT EXISTS ai_knowledge_chunks_embedding_idx
|
|
ON ai_knowledge_chunks USING ivfflat (embedding vector_cosine_ops)
|
|
WITH (lists = 100);
|
|
|
|
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).
|
|
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('english', p_query)) AS rank
|
|
FROM ai_knowledge_chunks c
|
|
WHERE c.account_id = p_account_id
|
|
AND c.fts @@ plainto_tsquery('english', 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.
|
|
CREATE OR REPLACE FUNCTION public.match_ai_knowledge_semantic(
|
|
p_account_id uuid,
|
|
p_query_embedding vector(1536),
|
|
p_match_count integer
|
|
)
|
|
RETURNS TABLE (id uuid, content text, distance real) AS $$
|
|
SELECT c.id,
|
|
c.content,
|
|
(c.embedding <=> p_query_embedding) 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
|
|
LIMIT GREATEST(p_match_count, 0);
|
|
$$ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public;
|