yau-plant-assistant/compose/ai-compose.yml
Claude 98083cd8d6 Design Phase 9 - operator document management
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>
2026-08-21 13:17:38 +10:00

199 lines
7.5 KiB
YAML

# =============================================================================
# ai-compose.yml -> deployed to ~/ai-compose.yml on yau-sls-poc-lin001
#
# House style, inherited from ~/docker-compose.yml (host brief section 7):
# - restart: unless-stopped on everything
# - log rotation 10 MB x 3 on everything
# - NO published host ports: reach services through Caddy on the proxy network
# - secrets in 0600 env files under ~/ai/, never here and never in Git
#
# Orphan-container warnings are expected (shared Compose project name) - ignore.
#
# docker compose -f ~/ai-compose.yml up -d
# =============================================================================
services:
# ---------------------------------------------------------------------------
# pg-ai - pgvector, reference data, Cube pre-aggregations.
# Deliberately NOT on proxy: no UI, nothing outside the AI stack reaches it.
# Pinned image - do NOT add to Watchtower's update list.
# ---------------------------------------------------------------------------
pg-ai:
image: pgvector/pgvector:pg16
container_name: pg-ai
restart: unless-stopped
networks: [ai-internal]
env_file:
- /home/azureuser/ai/pg-ai.env # 0600, not in Git
environment:
POSTGRES_DB: plant
POSTGRES_USER: postgres
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- /datadisk/pg-ai:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d plant"]
interval: 10s
timeout: 5s
retries: 5
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
# ---------------------------------------------------------------------------
# cube - semantic layer. Reads imh over TDS/1433 with the read-only login, or
# the fixture tables in pg-ai while USE_FIXTURES=true. Writes pre-aggregations
# into pg-ai schema cube_preagg. Pinned - not in Watchtower's list.
# ---------------------------------------------------------------------------
cube:
image: cubejs/cube:v1.1.7
container_name: cube
restart: unless-stopped
depends_on:
pg-ai:
condition: service_healthy
networks: [ai-internal, proxy]
env_file:
- /home/azureuser/ai/api.env # 0600, not in Git
environment:
CUBEJS_DEV_MODE: "false"
CUBEJS_LOG_LEVEL: warn
# Pre-aggregation store - always pg-ai, whatever the upstream source is.
CUBEJS_PRE_AGGREGATIONS_SCHEMA: cube_preagg
CUBEJS_EXT_DB_TYPE: postgres
CUBEJS_EXT_DB_HOST: pg-ai
CUBEJS_EXT_DB_NAME: plant
CUBEJS_EXT_DB_USER: cube_rw
# CUBEJS_EXT_DB_PASS, CUBEJS_DB_* and CUBEJS_API_SECRET come from api.env.
volumes:
- /home/azureuser/ai/cube/model:/cube/conf/model:ro
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:4000/readyz || exit 1"]
interval: 30s
timeout: 5s
retries: 3
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
# ---------------------------------------------------------------------------
# ai-api - FastAPI. Classifier, agent, contracts, guardrails.
# ---------------------------------------------------------------------------
ai-api:
build:
context: /home/azureuser/ai/api
dockerfile: Dockerfile
image: yau/ai-api:local
container_name: ai-api
restart: unless-stopped
depends_on:
pg-ai:
condition: service_healthy
networks: [ai-internal, proxy]
env_file:
- /home/azureuser/ai/api.env # 0600, not in Git
volumes:
# Phase 9 - the upload inbox, and the ONLY writable path this container
# has. Deliberately not /datadisk/ai-docs: a file that has been uploaded
# but not yet approved must not be visible to `ai-ingest --all`.
- /datadisk/ai-docs-inbox:/inbox
healthcheck:
test: ["CMD", "python", "-m", "app_healthcheck"]
interval: 30s
timeout: 5s
retries: 3
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
# ---------------------------------------------------------------------------
# ai-web - React/Vite build served by nginx. proxy only; the browser talks to
# the API through its public hostname, so it needs nothing on ai-internal.
# ---------------------------------------------------------------------------
ai-web:
build:
context: /home/azureuser/ai/web
dockerfile: Dockerfile
image: yau/ai-web:local
container_name: ai-web
restart: unless-stopped
networks: [proxy]
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
# ---------------------------------------------------------------------------
# ai-ingest - on demand, not a service. Docling -> chunk -> embed -> pg-ai.
# docker compose -f ~/ai-compose.yml run --rm ai-ingest --all
# The profile keeps it out of `up -d`.
# ---------------------------------------------------------------------------
ai-ingest:
build:
context: /home/azureuser/ai/ingest
dockerfile: Dockerfile
image: yau/ai-ingest:local
container_name: ai-ingest
profiles: [ingest]
restart: "no"
networks: [ai-internal]
env_file:
- /home/azureuser/ai/api.env # 0600, not in Git
volumes:
- /datadisk/ai-docs:/docs:ro
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
# ---------------------------------------------------------------------------
# ai-docs-worker - Phase 9. The ai-ingest IMAGE with worker.py as entrypoint,
# so an uploaded document is parsed and chunked by exactly the same code as a
# file ingested from the command line - by construction, not by discipline.
#
# Two jobs: pre-scan `uploaded` rows for a header proposal, and publish
# `approved` ones. It is the only container that can write doc_chunks
# (ingest_rw), and it has no HTTP surface and no place on the proxy network.
#
# /docs is READ-WRITE here, unlike the ai-ingest CLI service above, because
# publishing moves the approved file into the folder that determines its
# doc_type. That is the one write, and it happens only after a human has
# confirmed the header.
#
# Both mounts must be writable by the image's uid 10002 (ingestuser):
# sudo install -d -o 10002 -g 10002 /datadisk/ai-docs-inbox
# sudo chown -R 10002:10002 /datadisk/ai-docs
# ai-api writes the inbox as its own non-root uid - give the inbox group
# write and put both uids in the group rather than making it world-writable.
# ---------------------------------------------------------------------------
ai-docs-worker:
build:
context: /home/azureuser/ai/ingest
dockerfile: Dockerfile
image: yau/ai-ingest:local
container_name: ai-docs-worker
restart: unless-stopped
depends_on:
pg-ai:
condition: service_healthy
networks: [ai-internal]
env_file:
- /home/azureuser/ai/api.env # 0600, not in Git
entrypoint: ["python", "worker.py"]
command: []
volumes:
- /datadisk/ai-docs-inbox:/inbox
- /datadisk/ai-docs:/docs # rw - see above
# Withdrawn documents are MOVED here, not deleted. It is outside the four
# doc_type folders on purpose: ingest_file() inserts every chunk with
# superseded = FALSE, so a withdrawn file left in /docs comes back LIVE on
# the next `ai-ingest --all`.
- /datadisk/ai-docs-withdrawn:/withdrawn
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
networks:
ai-internal:
driver: bridge
proxy:
external: true