Operators cannot add a document today: ingestion is CLI-only, needs a host
login and a TTY for confirm_header(), /datadisk/ai-docs is mounted read-only,
ai-api has no identity, and nothing in the stack has a role that can write
doc_chunks. This designs the way in, the way out, and control over what is in
the retrieval pool. Design and schema only - no router, worker or UI code yet.
Documents in (16.1-16.9, db/004):
upload -> pre-scan -> review -> approve -> published, with the header
confirmation moved from a terminal prompt to a review screen and recorded
rather than discarded. A CHECK constraint refuses an approved row without a
confirmed number, revision and effective date, so an API bug cannot skip it.
Three roles: agent_ro unchanged, uploads_rw writes the queue only, ingest_rw
writes doc_chunks and has no HTTP surface.
Documents out (16.10-16.11, db/005):
--supersede needs a revision to keep, so a cancelled procedure cannot be
withdrawn at all. Adds withdraw (immediate, reversible, audited), restore
(refused while another revision is live) and purge (off by default). A
column grant plus a trigger let the web-facing role make a document less
citable and never more.
The pool (16.13-16.15, db/006):
pool_enabled, orthogonal to superseded: one is a claim about the document,
the other about the corpus. Retrieval requires both, so re-enabling a
withdrawn document does not make it citable. Named profiles and a
per-request override let a demo trim the corpus without mutating state on a
shared live host, and every reduced-pool answer carries a banner with the
document count, following the used_fixture_data precedent.
Two existing defects found and documented while designing this:
- ai-ingest takes PGUSER=agent_ro from api.env, a SELECT-only role, so the
Phase 3 command in the README cannot write doc_chunks (16.1).
- ingest_file() always inserts superseded = FALSE, so `--all` re-ingests a
superseded revision as live. --supersede survives only until the next bulk
run (16.10).
One commit rather than three: the upload, withdrawal and pool designs
interleave in the same spec, README and compose files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
202 lines
10 KiB
PL/PgSQL
202 lines
10 KiB
PL/PgSQL
-- =============================================================================
|
|
-- 006_doc_pool.sql — which documents are in the retrieval pool.
|
|
--
|
|
-- psql -h pg-ai -U postgres -d plant -f 006_doc_pool.sql
|
|
--
|
|
-- Applied at Phase 9, after 005. Additive: `pool_enabled` defaults TRUE, so
|
|
-- every document already ingested stays exactly where it was.
|
|
--
|
|
-- TWO REASONS A CHUNK IS NOT RETRIEVABLE, AND THEY MUST NOT BE CONFLATED:
|
|
--
|
|
-- superseded = TRUE A statement ABOUT THE DOCUMENT. It is withdrawn, or
|
|
-- a newer revision replaced it. Document-control state.
|
|
-- Safety-meaningful. Changing it is an operational act
|
|
-- with a reason and an audit row (005).
|
|
--
|
|
-- pool_enabled = FALSE A statement ABOUT THE CORPUS. This document is not
|
|
-- part of the set we are running with. It says nothing
|
|
-- about whether the document is valid or current.
|
|
-- Curation, and — see section 16.13 — demonstration.
|
|
--
|
|
-- Retrieval requires BOTH: superseded = FALSE AND pool_enabled. That is what
|
|
-- makes them orthogonal and safe to expose separately. Re-enabling a withdrawn
|
|
-- document in the pool does NOT make it citable again; only a restore does.
|
|
-- Somebody curating the pool cannot accidentally resurrect a withdrawn
|
|
-- procedure, which is the mistake this separation is here to make impossible.
|
|
--
|
|
-- If these two ever collapse into one flag, a demo that trimmed the corpus
|
|
-- becomes indistinguishable from a document that was withdrawn on purpose.
|
|
-- =============================================================================
|
|
|
|
ALTER TABLE doc_chunks
|
|
ADD COLUMN IF NOT EXISTS pool_enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
|
|
|
-- The retrieval predicate, as an index. Replaces doc_chunks_live_type as the
|
|
-- filter that matches what tools/retrieval.py actually asks for.
|
|
CREATE INDEX IF NOT EXISTS doc_chunks_retrievable
|
|
ON doc_chunks (doc_type)
|
|
WHERE superseded = FALSE AND pool_enabled;
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- READ THIS BEFORE DISABLING MOST OF THE CORPUS.
|
|
--
|
|
-- The HNSW index is built over EVERY embedding, superseded and disabled rows
|
|
-- included. An approximate scan finds the k nearest vectors and the WHERE
|
|
-- clause filters afterwards, so if 90% of the pool is disabled — exactly what a
|
|
-- coverage demo does — the scan can come back with almost nothing even though
|
|
-- relevant enabled documents exist. The failure looks like "retrieval got
|
|
-- worse", which in a demo about corpus size is the single most misleading
|
|
-- result available.
|
|
--
|
|
-- With a corpus this small (thousands of chunks, not millions) the fix is
|
|
-- cheap. Either raise the candidate list for the query:
|
|
--
|
|
-- SET LOCAL hnsw.ef_search = 200;
|
|
--
|
|
-- or, when the enabled fraction is low, drop to an exact scan for that query:
|
|
--
|
|
-- SET LOCAL enable_indexscan = off; -- sequential + exact, milliseconds here
|
|
--
|
|
-- tools/retrieval.py should do the second automatically below a threshold
|
|
-- (POOL_EXACT_SCAN_BELOW_PCT). Measure it before the demo, not during.
|
|
-- -----------------------------------------------------------------------------
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- doc_pool_profiles — a named set of documents, applied and reverted as one.
|
|
--
|
|
-- "Show them the answer with three documents, then with all forty-seven" is not
|
|
-- forty-four checkbox clicks, and it must be revertible in one action, in front
|
|
-- of an audience, without anyone wondering afterwards whether the pool was left
|
|
-- trimmed. A profile is a saved selection, not a copy of the documents.
|
|
--
|
|
-- Profiles are also how a demo avoids touching global state at all: the
|
|
-- intended path is a PER-REQUEST override naming a profile (section 16.14),
|
|
-- which changes nothing stored and leaks nothing to the operator asking a real
|
|
-- question on the same shared host at the same time.
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS doc_pool_profiles (
|
|
profile_id UUID PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE, -- 'full', 'procedures-only', 'minimal-3'
|
|
description TEXT,
|
|
is_builtin BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_by TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS doc_pool_profile_members (
|
|
profile_id UUID NOT NULL REFERENCES doc_pool_profiles(profile_id) ON DELETE CASCADE,
|
|
source_file TEXT NOT NULL,
|
|
PRIMARY KEY (profile_id, source_file)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS doc_pool_members_file
|
|
ON doc_pool_profile_members (source_file);
|
|
|
|
-- 'full' is the only profile with no members: it means "no restriction", not
|
|
-- "no documents". Special-cased in one place in the API and nowhere else.
|
|
INSERT INTO doc_pool_profiles (profile_id, name, description, is_builtin)
|
|
VALUES ('00000000-0000-0000-0000-000000000001',
|
|
'full',
|
|
'Every enabled, live document. The operational default.',
|
|
TRUE)
|
|
ON CONFLICT (name) DO NOTHING;
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- Curating the pool is an audited act, like withdrawing.
|
|
-- 005 already holds the trail; it gains two more verbs.
|
|
-- -----------------------------------------------------------------------------
|
|
ALTER TABLE doc_actions DROP CONSTRAINT IF EXISTS doc_actions_action_ck;
|
|
ALTER TABLE doc_actions ADD CONSTRAINT doc_actions_action_ck
|
|
CHECK (action IN ('withdraw','restore','purge','pool_disable','pool_enable'));
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- pool_status — what the assistant is actually running with right now.
|
|
--
|
|
-- The number in the corner of the screen. An operator should be able to see
|
|
-- that the pool is not whole without asking anybody, and a demo should not be
|
|
-- able to hide it.
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE OR REPLACE VIEW pool_status AS
|
|
SELECT count(DISTINCT source_file) FILTER (
|
|
WHERE superseded = FALSE AND pool_enabled) AS documents_in_pool,
|
|
count(DISTINCT source_file) FILTER (WHERE superseded = FALSE) AS documents_live,
|
|
count(*) FILTER (WHERE superseded = FALSE AND pool_enabled) AS chunks_in_pool,
|
|
count(*) FILTER (WHERE superseded = FALSE) AS chunks_live
|
|
FROM doc_chunks;
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- pool_documents — the curation screen's list. Every live document, in or out.
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE OR REPLACE VIEW pool_documents AS
|
|
SELECT source_file,
|
|
max(doc_number) AS doc_number,
|
|
max(revision) AS revision,
|
|
max(doc_type) AS doc_type,
|
|
max(effective_date) AS effective_date,
|
|
count(*) AS chunk_count,
|
|
bool_and(pool_enabled) AS pool_enabled,
|
|
max(created_at) AS ingested_at
|
|
FROM doc_chunks
|
|
WHERE superseded = FALSE
|
|
GROUP BY source_file;
|
|
|
|
-- =============================================================================
|
|
-- Grants.
|
|
--
|
|
-- pool_enabled is NOT a safety flag, so uploads_rw may set it in BOTH
|
|
-- directions - unlike `superseded`, where the trigger in 005 allows only
|
|
-- withdrawal. Being in the pool is not a claim that a document is current;
|
|
-- `superseded` is still the only thing that says that, and it is still the only
|
|
-- thing the web-facing role cannot undo.
|
|
-- =============================================================================
|
|
|
|
GRANT UPDATE (pool_enabled) ON doc_chunks TO uploads_rw;
|
|
GRANT SELECT, INSERT, UPDATE, DELETE ON doc_pool_profiles TO uploads_rw;
|
|
GRANT SELECT, INSERT, UPDATE, DELETE ON doc_pool_profile_members TO uploads_rw;
|
|
GRANT SELECT ON doc_pool_profiles, doc_pool_profile_members TO agent_ro, ingest_rw;
|
|
GRANT SELECT ON pool_status, pool_documents TO agent_ro, uploads_rw, ingest_rw;
|
|
|
|
-- A profile is a selection, so deleting one destroys no documents. `full` is
|
|
-- protected because a demo that deletes the way back to the operational pool is
|
|
-- not a recoverable position in front of an audience.
|
|
CREATE OR REPLACE FUNCTION doc_pool_profiles_protect() RETURNS TRIGGER AS $fn$
|
|
BEGIN
|
|
IF OLD.is_builtin THEN
|
|
RAISE EXCEPTION 'the % profile is built in and cannot be % ',
|
|
OLD.name, TG_OP;
|
|
END IF;
|
|
RETURN OLD;
|
|
END;
|
|
$fn$ LANGUAGE plpgsql;
|
|
|
|
DROP TRIGGER IF EXISTS doc_pool_profiles_protect_tr ON doc_pool_profiles;
|
|
CREATE TRIGGER doc_pool_profiles_protect_tr BEFORE DELETE OR UPDATE
|
|
ON doc_pool_profiles
|
|
FOR EACH ROW EXECUTE FUNCTION doc_pool_profiles_protect();
|
|
|
|
-- =============================================================================
|
|
-- Phase 9 gate — the pool half.
|
|
--
|
|
-- Orthogonality, which is the property the whole file exists for:
|
|
-- UPDATE doc_chunks SET pool_enabled = TRUE
|
|
-- WHERE superseded = TRUE; -- allowed, and changes NOTHING:
|
|
-- -- the withdrawn document must still not be retrievable. Ask the question
|
|
-- -- that used to cite it and confirm it is not cited.
|
|
--
|
|
-- As uploads_rw:
|
|
-- UPDATE doc_chunks SET pool_enabled = FALSE WHERE ...; -- must work
|
|
-- UPDATE doc_chunks SET pool_enabled = TRUE WHERE ...; -- must work
|
|
-- UPDATE doc_chunks SET superseded = FALSE WHERE ...; -- must be REJECTED
|
|
-- DELETE FROM doc_pool_profiles WHERE name = 'full'; -- must be REJECTED
|
|
--
|
|
-- Retrieval, with most of the corpus disabled - the demo case:
|
|
-- Disable 90% of documents, then ask a question whose answer is in one of
|
|
-- the remaining 10%. It MUST still be found. If it is not, the HNSW note at
|
|
-- the top of this file is why, and the fix is there, not in the prompt.
|
|
--
|
|
-- The banner, which is the part that protects everyone else on this host:
|
|
-- With any document out of the pool, EVERY answer must carry the reduced-
|
|
-- pool banner, exactly as fixture data does. An answer from a trimmed
|
|
-- corpus that looks like an answer from the whole corpus is the failure
|
|
-- this feature introduces, and the banner is the whole mitigation.
|
|
-- =============================================================================
|