yau-plant-assistant/compose/ai-compose.yml
Claude fd85e62ebf Add the document library screens: upload, review, withdraw, restore
Phase 9's operator path, built ahead of Phase 8 at the customer's direction and
live at api.yokogawa.tech/documents. Upload, convert, review, approve, withdraw
and restore. The pool screen is explicitly out of scope.

Served by ai-api rather than ai-web, and mounted at /documents rather than
/docs. ai.yokogawa.tech is SCADA-only since 2026-08-28 and passes through no
Authelia, so it has no identity to record; publishers arrive on
api.yokogawa.tech where the forward-auth headers still do. /docs stays with
Swagger, which the customer is keeping - two things under one prefix with two
different access policies is what gets misread during a later edit.

Conversion is text extraction, not document parsing: pypdf, python-docx and
openpyxl. Docling would be better at this and pulls torch, which lin001 has
neither the memory to install nor the business running next to the demo plant's
PLC. The cost is real - no layout, no table structure, and a scan cannot be read
at all, so it is refused rather than stored empty. It is acceptable only because
the converted text is shown to a person before the document can be cited, which
is the same safety net the design already required for the header. convert.py is
the one file to change if that stops being true.

Chunking is mirrored from ingest.py rather than shared, because the two live in
different images. They must stay identical: if they drift, the same document
chunks differently depending on who loaded it, and the assistant answers or
fails to answer depending on that. The step-sequence rule is locked by a test.

Identity is self-asserted for the demo - the actor is typed on the form, which
section 16 forbids, and the publisher list is one name with no password. Rows are
written as `demo:<name>` with actor_groups = 'DEMO-UNVERIFIED' so that when real
auth goes on, a name somebody typed stays tellable from a name Authelia proved.
doc_actions cannot be deleted from, so an ambiguity there would be permanent.

Two rules the code enforces rather than documents: uploading is open to anyone
who reaches the page, because uploading changes nothing an operator can see -
approving does, and that is what is gated; and an empty publisher list means
nobody, not everybody.

Verified on the host end to end: withdraw as a non-publisher 403s, with a short
reason 400s, and as admin flips 5 chunks and writes a complete audit row;
restore puts them back and keeps both rows. The corpus is unchanged afterwards.

Requirements are split so the document dependencies install in their own layer -
a change there costs four small wheels instead of re-resolving fastapi,
langgraph and langfuse on a 2 vCPU shared host.

The five divergences from section 16 are recorded in section 14. The one with
teeth: files published through the UI stay in the inbox, so `ai-ingest --all`
cannot see them and the two paths must not be used on the same document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:13:17 +10:00

271 lines
11 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" }
# ---------------------------------------------------------------------------
# cubestore - Cube's own store. Queue, cache and pre-aggregations. Deployed
# because Cube v1 does not run without it, not because we wanted another
# container. Version pinned in lockstep with cube: a mismatched pair is a
# documented Cube failure mode. Do NOT add either to Watchtower's list.
#
# Data goes on /datadisk. It grows with the pre-aggregations, and / is 62 GB
# and has hit 100% on this host before.
# sudo install -d -o 1000 -g 1000 /datadisk/cubestore
# No ports, ai-internal only: nothing outside the AI stack talks to it, and
# it has no authentication of its own.
# ---------------------------------------------------------------------------
cubestore:
image: cubejs/cubestore:v1.1.7
container_name: cubestore
restart: unless-stopped
networks: [ai-internal]
environment:
CUBESTORE_DATA_DIR: /cube/data
volumes:
- /datadisk/cubestore:/cube/data
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. Pinned - not in
# Watchtower's list.
#
# PRE-AGGREGATIONS LIVE IN CUBE STORE, NOT pg-ai. This is a deviation from
# the build spec, forced by the pinned version and found on lin001 deploying
# Phase 5, not in review. Cube v1 will not materialise pre-aggregations into
# Postgres - Cube Store is the only supported external store - so naming
# Postgres as CUBEJS_EXT_DB_TYPE fails every query with
# "It`s not possible to use Cube Store as queue/cache driver without using
# it as external"
# Cube Store is also the queue and cache driver, and it is not optional:
# CUBEJS_CACHE_AND_QUEUE_DRIVER=memory does not fall back, it hangs /readyz
# and every query forever, with nothing in the log at level warn.
# So `cubestore` below is a hard dependency of cube, not an optimisation.
# cube_preagg in pg-ai and its grants in db/003_roles.sql are now unused;
# they are left in place rather than dropped, because nothing else changes if
# a future Cube version restores Postgres as an external store.
# ---------------------------------------------------------------------------
cube:
image: cubejs/cube:v1.1.7
container_name: cube
restart: unless-stopped
depends_on:
pg-ai:
condition: service_healthy
cubestore:
condition: service_started
networks: [ai-internal, proxy]
env_file:
- /home/azureuser/ai/api.env # 0600, not in Git
environment:
CUBEJS_DEV_MODE: "false"
CUBEJS_LOG_LEVEL: warn
# Queue, cache and pre-aggregation store. Mandatory - see the note above.
CUBEJS_CUBESTORE_HOST: cubestore
CUBEJS_CUBESTORE_PORT: "3030"
# Without this NOTHING builds the pre-aggregations, and every query that
# matches one fails with "No pre-aggregation partitions were built yet"
# rather than falling back to the source. Single node, so the API
# instance is also the refresh worker.
CUBEJS_REFRESH_WORKER: "true"
# CUBEJS_DB_* and CUBEJS_API_SECRET come from api.env.
volumes:
- /home/azureuser/ai/cube/model:/cube/conf/model:ro
healthcheck:
# The image has neither wget nor curl - the original wget healthcheck
# marked a perfectly healthy cube unhealthy on every deploy. node is
# what it does have.
test: ["CMD", "node", "-e", "require('http').get('http://localhost:4000/readyz', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.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
# Phase 9 - the upload inbox, and the ONLY writable path this container
# gets. Deliberately not /datadisk/ai-docs: a file that has been uploaded
# but not yet approved must not be visible to `ai-ingest --all`.
#
# ENABLED 2026-08-28. Docker creates a missing bind source as a ROOT-OWNED
# directory and ai-api does not run as root, so the directory must exist
# with the right owner BEFORE this comes up:
# sudo install -d -o 10001 -g 10001 /datadisk/ai-docs-inbox
#
# 10001, not the 10002 this comment used to say. 10002 is the ingest
# image's user; there is no ai-docs-worker in this build, so the process
# writing the inbox is ai-api's appuser at 10001. Check the Dockerfile
# rather than this line if it ever moves.
#
# /datadisk, not /: the root disk is 62 GB and uploads accumulate. And NOT
# /datadisk/ai-docs - a file that has been uploaded but not yet approved
# must not be visible to `ai-ingest --all`.
volumes:
- /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
args:
# Empty in a real deployment: the page is same-origin, because Caddy
# routes /ask under ai.yokogawa.tech to ai-api. Set only for a
# tunnelled demo build, which has no Caddy and no Authelia in front
# of it.
VITE_API_BASE: "${VITE_API_BASE:-}"
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
# Kept out of `up -d` by the profile, exactly as ai-ingest is. worker.py
# does not exist yet, so an unguarded service here would crash-loop on a
# live shared host. Drop the profile in the same commit that adds the file.
# docker compose -f ~/ai-compose.yml --profile worker up -d ai-docs-worker
profiles: [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