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>
269 lines
13 KiB
PL/PgSQL
269 lines
13 KiB
PL/PgSQL
-- =============================================================================
|
|
-- 004_doc_uploads.sql — operator document upload, review and publication.
|
|
--
|
|
-- psql -h pg-ai -U postgres -d plant -f 004_doc_uploads.sql
|
|
--
|
|
-- Applied at Phase 9. Additive: it does not alter doc_chunks, and the answer
|
|
-- path behaves identically whether this table exists or not.
|
|
--
|
|
-- WHAT THIS TABLE IS. The queue and the audit trail for documents that arrive
|
|
-- through the UI instead of by SSH. One row per uploaded file, from the moment
|
|
-- it lands in the inbox to the moment its chunks are citable — including who
|
|
-- uploaded it, who confirmed its identity, and exactly what they confirmed.
|
|
--
|
|
-- WHAT IT IS NOT. It is not document control. It records what a named person
|
|
-- asserted about a file on a date; it does not know what the current revision
|
|
-- of WRPS-OPS-014 actually is. See section 16 of BUILD-AI-CONTAINERS.md.
|
|
--
|
|
-- THE RULE THIS TABLE EXISTS TO PRESERVE: a wrong revision on a procedure is a
|
|
-- safety issue, not a data-quality one. ingest.py asks a human at a terminal.
|
|
-- Nothing about a web form removes that requirement — it moves the question
|
|
-- from a terminal prompt to a review screen, and records the answer instead of
|
|
-- discarding it.
|
|
-- =============================================================================
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- The state machine. A CHECK rather than an enum, because adding a value to an
|
|
-- enum needs an ALTER TYPE that will not run inside the rest of a migration.
|
|
--
|
|
-- uploaded file written to the inbox, nothing parsed yet
|
|
-- | (ai-docs-worker picks it up)
|
|
-- v
|
|
-- scanning Docling parsing; header proposal being extracted
|
|
-- |
|
|
-- v
|
|
-- awaiting_review proposal ready; a human must confirm or correct it
|
|
-- | \
|
|
-- | \--> rejected reviewer refused it; file stays in the inbox
|
|
-- v
|
|
-- approved header CONFIRMED BY A NAMED PERSON; supersede decided
|
|
-- | (worker claims it)
|
|
-- v
|
|
-- ingesting file moved into /docs/<folder>/, chunked and embedded
|
|
-- | \
|
|
-- | \--> failed error recorded; retryable, file is in place
|
|
-- v
|
|
-- published chunks live in doc_chunks and citable
|
|
--
|
|
-- Only `approved` causes chunks to be written. There is no transition from
|
|
-- `uploaded` to `ingesting`. That is the design, not an omission.
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS doc_uploads (
|
|
upload_id UUID PRIMARY KEY,
|
|
status TEXT NOT NULL DEFAULT 'uploaded',
|
|
|
|
-- --- the file -----------------------------------------------------------
|
|
original_filename TEXT NOT NULL, -- as the operator's browser sent it
|
|
stored_path TEXT NOT NULL, -- /inbox/<upload_id>/<safe_filename>
|
|
content_type TEXT,
|
|
size_bytes BIGINT NOT NULL,
|
|
sha256 TEXT NOT NULL, -- dedupe, and proof the bytes are unchanged
|
|
page_count INT,
|
|
preview_text TEXT, -- first page, for the review screen
|
|
|
|
-- --- who ----------------------------------------------------------------
|
|
-- From Authelia's Remote-User / Remote-Name, forwarded by Caddy. NEVER from
|
|
-- the request body: a browser must not be able to name its own uploader.
|
|
uploaded_by TEXT NOT NULL,
|
|
uploaded_by_name TEXT,
|
|
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
uploader_note TEXT, -- "assist pump start logic changed, rev C"
|
|
|
|
-- --- what the uploader said it is (a proposal, never authoritative) ------
|
|
proposed_doc_type TEXT,
|
|
detected_doc_number TEXT,
|
|
detected_revision TEXT,
|
|
detected_effective_date DATE,
|
|
|
|
-- --- what a human confirmed ---------------------------------------------
|
|
-- These are the values that reach doc_chunks. They stay NULL until somebody
|
|
-- in the publisher group types or accepts them.
|
|
confirmed_doc_type TEXT,
|
|
confirmed_doc_number TEXT,
|
|
confirmed_revision TEXT,
|
|
confirmed_effective_date DATE,
|
|
supersede_previous BOOLEAN, -- withdraw other revisions of this doc_number
|
|
reference_data_checked BOOLEAN, -- see the note below — not cosmetic
|
|
reviewed_by TEXT,
|
|
reviewed_by_name TEXT,
|
|
reviewed_at TIMESTAMPTZ,
|
|
review_note TEXT, -- required when rejecting
|
|
|
|
-- --- outcome ------------------------------------------------------------
|
|
published_source_file TEXT, -- the doc_chunks.source_file key
|
|
chunk_count INT,
|
|
superseded_count INT,
|
|
error TEXT, -- operator-readable; never a stack trace
|
|
attempts INT NOT NULL DEFAULT 0,
|
|
claimed_at TIMESTAMPTZ, -- worker lease, for stuck-job detection
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
|
|
CONSTRAINT doc_uploads_status_ck CHECK (status IN (
|
|
'uploaded','scanning','awaiting_review','rejected',
|
|
'approved','ingesting','published','failed')),
|
|
|
|
-- doc_type still comes from a fixed list, and on approval the file is moved
|
|
-- into the folder that matches it. The folder stays the on-disk truth.
|
|
CONSTRAINT doc_uploads_proposed_type_ck CHECK (
|
|
proposed_doc_type IS NULL OR proposed_doc_type IN
|
|
('procedure','manual','rationalisation','design')),
|
|
CONSTRAINT doc_uploads_confirmed_type_ck CHECK (
|
|
confirmed_doc_type IS NULL OR confirmed_doc_type IN
|
|
('procedure','manual','rationalisation','design')),
|
|
|
|
-- An approved row with an incomplete confirmed header is the exact defect
|
|
-- this design exists to prevent. Refuse it in the database, not only in the
|
|
-- API — the API is one bad deployment away from being bypassed.
|
|
CONSTRAINT doc_uploads_approved_needs_header_ck CHECK (
|
|
status NOT IN ('approved','ingesting','published')
|
|
OR (confirmed_doc_type IS NOT NULL
|
|
AND confirmed_doc_number IS NOT NULL
|
|
AND confirmed_revision IS NOT NULL
|
|
AND confirmed_effective_date IS NOT NULL
|
|
AND supersede_previous IS NOT NULL
|
|
AND reviewed_by IS NOT NULL)),
|
|
|
|
CONSTRAINT doc_uploads_rejected_needs_reason_ck CHECK (
|
|
status <> 'rejected'
|
|
OR (reviewed_by IS NOT NULL AND review_note IS NOT NULL))
|
|
);
|
|
|
|
-- The scan the worker runs every few seconds. Partial: the interesting rows are
|
|
-- a handful at a time, the published ones accumulate forever.
|
|
CREATE INDEX IF NOT EXISTS doc_uploads_pending
|
|
ON doc_uploads (uploaded_at)
|
|
WHERE status IN ('uploaded','approved');
|
|
|
|
CREATE INDEX IF NOT EXISTS doc_uploads_status_recent
|
|
ON doc_uploads (status, uploaded_at DESC);
|
|
|
|
-- The same bytes uploaded twice is almost always a double-click or a re-send,
|
|
-- not a second document. Not UNIQUE: re-uploading after a rejection is
|
|
-- legitimate, and the API decides, having seen the earlier row.
|
|
CREATE INDEX IF NOT EXISTS doc_uploads_sha256 ON doc_uploads (sha256);
|
|
|
|
CREATE OR REPLACE FUNCTION doc_uploads_touch() RETURNS TRIGGER AS $fn$
|
|
BEGIN
|
|
NEW.updated_at := now();
|
|
RETURN NEW;
|
|
END;
|
|
$fn$ LANGUAGE plpgsql;
|
|
|
|
DROP TRIGGER IF EXISTS doc_uploads_touch_tr ON doc_uploads;
|
|
CREATE TRIGGER doc_uploads_touch_tr BEFORE UPDATE ON doc_uploads
|
|
FOR EACH ROW EXECUTE FUNCTION doc_uploads_touch();
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- reference_data_checked — why a boolean on this table earns its place.
|
|
--
|
|
-- This upload path exists because PLC logic and SCADA programs change and a new
|
|
-- document is issued. Ingesting that document changes what the assistant can
|
|
-- CITE. It does not change db/seed/tags.csv, the Cube models, the alarm
|
|
-- setpoints in `tags`, or anything else the numeric answers are computed from.
|
|
--
|
|
-- So a design document describing a new interlock can go live while every
|
|
-- Historical and Advisory answer is still built on the old tag metadata — and
|
|
-- both look equally confident on screen.
|
|
--
|
|
-- The review screen asks the reviewer to confirm they have considered that. It
|
|
-- is an acknowledgement, not a check; nothing here can verify it. It exists so
|
|
-- the gap is visible at the one moment somebody can still act on it, and so the
|
|
-- audit trail shows who was asked.
|
|
-- -----------------------------------------------------------------------------
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- live_documents — what is currently citable, one row per document revision.
|
|
--
|
|
-- The review screen needs this to answer "what am I about to supersede?" before
|
|
-- the reviewer ticks the box, not after.
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE OR REPLACE VIEW live_documents AS
|
|
SELECT doc_number,
|
|
revision,
|
|
doc_type,
|
|
max(effective_date) AS effective_date,
|
|
min(source_file) AS source_file,
|
|
count(*) AS chunk_count,
|
|
max(created_at) AS ingested_at
|
|
FROM doc_chunks
|
|
WHERE superseded = FALSE
|
|
GROUP BY doc_number, revision, doc_type;
|
|
|
|
-- =============================================================================
|
|
-- Roles. Three, not one, and the split is the point.
|
|
--
|
|
-- agent_ro the answer path. SELECT only, everywhere. UNCHANGED here — it
|
|
-- gains no write anywhere, including on this table.
|
|
-- uploads_rw ai-api. Writes the QUEUE and nothing else. It cannot write
|
|
-- doc_chunks, so no defect in an HTTP endpoint can put a chunk in
|
|
-- front of an operator without a human approval in between.
|
|
-- ingest_rw ai-docs-worker. Writes doc_chunks. Has no HTTP surface at all
|
|
-- and is not on the proxy network.
|
|
--
|
|
-- ai-api therefore holds two connections: the existing agent_ro one for
|
|
-- answering, and a uploads_rw one for the document screens. Do not collapse
|
|
-- them into one role that can do both.
|
|
-- =============================================================================
|
|
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'uploads_rw') THEN
|
|
CREATE ROLE uploads_rw LOGIN;
|
|
END IF;
|
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'ingest_rw') THEN
|
|
CREATE ROLE ingest_rw LOGIN;
|
|
END IF;
|
|
END
|
|
$$;
|
|
|
|
-- Passwords are NOT in this file. Set them from the 0600 env files, as in
|
|
-- 003_roles.sql.
|
|
|
|
-- --- uploads_rw — the queue, and read-only on everything else ----------------
|
|
GRANT CONNECT ON DATABASE plant TO uploads_rw;
|
|
GRANT USAGE ON SCHEMA public TO uploads_rw;
|
|
GRANT SELECT ON ALL TABLES IN SCHEMA public TO uploads_rw;
|
|
GRANT INSERT, UPDATE ON doc_uploads TO uploads_rw;
|
|
-- No DELETE: a rejected upload is history, not a mistake to erase.
|
|
REVOKE DELETE ON doc_uploads FROM uploads_rw;
|
|
-- Explicit, and re-checked at the Phase 9 gate: no write on doc_chunks.
|
|
REVOKE INSERT, UPDATE, DELETE ON doc_chunks FROM uploads_rw;
|
|
REVOKE CREATE ON SCHEMA public FROM uploads_rw;
|
|
REVOKE TEMPORARY ON DATABASE plant FROM uploads_rw;
|
|
|
|
-- --- ingest_rw — the worker -------------------------------------------------
|
|
GRANT CONNECT ON DATABASE plant TO ingest_rw;
|
|
GRANT USAGE ON SCHEMA public TO ingest_rw;
|
|
GRANT SELECT ON ALL TABLES IN SCHEMA public TO ingest_rw;
|
|
GRANT INSERT, UPDATE, DELETE ON doc_chunks TO ingest_rw;
|
|
GRANT USAGE, SELECT ON SEQUENCE doc_chunks_id_seq TO ingest_rw;
|
|
GRANT INSERT, UPDATE ON doc_uploads TO ingest_rw;
|
|
REVOKE CREATE ON SCHEMA public FROM ingest_rw;
|
|
REVOKE TEMPORARY ON DATABASE plant FROM ingest_rw;
|
|
|
|
-- The answer path reads the queue — so the UI can tell an operator a document
|
|
-- is pending rather than silently not finding it — and writes nothing.
|
|
GRANT SELECT ON doc_uploads TO agent_ro;
|
|
GRANT SELECT ON live_documents TO agent_ro, uploads_rw, ingest_rw;
|
|
|
|
-- =============================================================================
|
|
-- Phase 9 gate — verify, do not assume. Prove each role separately.
|
|
--
|
|
-- As agent_ro:
|
|
-- SELECT count(*) FROM doc_uploads; -- must work
|
|
-- UPDATE doc_uploads SET status = 'approved'; -- must be REJECTED
|
|
--
|
|
-- As uploads_rw:
|
|
-- INSERT INTO doc_uploads (...) VALUES (...); -- must work
|
|
-- INSERT INTO doc_chunks (source_file, doc_type, chunk_text)
|
|
-- VALUES ('x','manual','x'); -- must be REJECTED
|
|
-- DELETE FROM doc_uploads; -- must be REJECTED
|
|
--
|
|
-- As anyone — the constraint that carries the safety rule:
|
|
-- UPDATE doc_uploads SET status = 'approved'
|
|
-- WHERE upload_id = '<a row with no confirmed header>';
|
|
-- -- must be REJECTED
|
|
--
|
|
-- An approved row with a NULL confirmed_revision is a Phase 9 failure, not a
|
|
-- detail to fix later.
|
|
-- =============================================================================
|