Azure OpenAI is pending and imh is pending, so POST /ask could not return
anything at all - which left the entire chain either side of the model
unproven: the browser, the API, entity resolution, Cube, retrieval, the
contracts, the banners, the error paths. All of it is testable now, and waiting
for a key to find out whether it works is a choice to find out later.
NO_LLM_STUB=true substitutes the two steps that need a model and nothing else.
- Classification: the caller supplies the class, from a dropdown in the UI.
NOT a keyword classifier. A crude keyword classifier produces a PLAUSIBLE
label, and a plausible wrong label is the exact failure this system exists
to prevent - "how do I reset it" landing in Historical is how a synthesised
procedure reaches an operator. Choosing by hand is honest about what is
happening and drives each branch deliberately. apply_safety_rules() still
runs over the result.
- Prose: a fixed placeholder per class, in stub.py.
Everything else is the real path. This is possible because generate() already
kept the factual fields away from the model: rows, counts, citations, the
fixture flag and the class are attached from evidence, and only prose comes
from the generator. Splitting that into _generate_prose() and _assemble() makes
the seam explicit - the stub feeds _assemble() exactly as the model does, so
this is a fair test of the assembly path rather than a mock of it.
The contracts are the point. A stub payload goes through enforce_contract()
unchanged, and it FAILED first time on two classes: the "nothing found" wording
did not match the not-found detectors, so Reference and Procedural returned 422
rather than an uncited answer. That is the contract doing its job against text
no model wrote. Retries are pointless on deterministic output, and a 422 is a
real result here, not a stub bug.
Retrieval is lexical (retrieval.lexical_search), because embedding the question
needs the model. Kept beside search() and never called on the normal path, so
nobody reads a trace and mistakes a lexical hit for a semantic one. It matches
what the operator typed, not what they meant.
What it does not prove: whether the classifier would have labelled correctly -
a person did; whether retrieval finds the RIGHT chunk; and nothing about prose.
It also cannot fill prerequisites_verbatim - extracting them with a regex would
be the "synthesised from fragments" failure the Procedural contract forbids, so
the list is empty and the answer says so.
Every answer carries stub_mode: true in the contract, not decorated on by the
UI, and a banner beside the fixture banner. Same reasoning: an answer nobody
generated must not be indistinguishable from one that was.
Also here:
- demo/ai-docs: three fabricated documents, numbered WRPS-DEMO-00x so header
extraction is genuinely exercised against a number no real WRPS document
can have. Their setpoints contradict tags.csv on purpose.
- VITE_API_BASE build arg, for a tunnelled build before DNS exists. The
tunnel origin is allowed in CORS only while NO_LLM_STUB is on, so it
disappears with the flag. Proxying /api through ai-web's nginx would have
been easier and was rejected: it creates a second route to the API that
bypasses the api.yokogawa.tech Caddy block, where the Phase 9 publisher
rule lives.
Verified on lin001 with no Azure key set at all: all five classes return 200
through the real UI in a browser, over an SSH tunnel, with citations from the
demo documents, real Cube numbers, and both banners showing.
Turning it off: NO_LLM_STUB=false in ~/ai/api.env, restart ai-api.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
264 lines
11 KiB
YAML
264 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`.
|
|
#
|
|
# COMMENTED OUT UNTIL PHASE 9, on the same principle as caddy/ai-routes.caddy:
|
|
# add each piece at the phase that needs it. Docker creates a missing bind
|
|
# source as a ROOT-OWNED directory, and ai-api does not run as root - so
|
|
# deploying this before the directory exists gives you an inbox the API
|
|
# cannot write to, on the growing disk of a live shared host.
|
|
# Create it first, then uncomment:
|
|
# sudo install -d -o 10002 -g 10002 /datadisk/ai-docs-inbox
|
|
# 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 browser reaches the API at its own
|
|
# public hostname through Caddy. 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
|