The exam: eval/testset.jsonl holds 78 cases (A10 H28 L8 N5 P10 R10 T5 U2).
workflow-map.html called it a 75-question exam in the two places it states
the current total. Corrected. The two sentences describing how the exam
"grew from 67 questions to 75" are left alone - that step is historically
correct: 67 plus the eight live-model failures is 75, and the Phase 5
findings H26, H27 and H31 took it to 78 afterwards.
The env files: .env.example said TWO 0600 files under ~/ai/ and listed
pg-ai.env and api.env, but its own line 105 refers to langfuse.env, and
README.md, scripts/deploy.sh and compose/langfuse-compose.yml all use
three. The header was simply wrong; langfuse.env is now named in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
ProcedureIdentity requires a title and an authorising role, and neither was
stored anywhere. The answer writer was asked for both, read them off whatever
chunk retrieval happened to return, and returned "" whenever the header chunk
was not among them.
They belong in the row for the same reason doc_number and revision do: they
are facts about the controlled document, established once when a human
confirms the header, not something to re-derive per question from whatever
text was retrieved. Denormalised onto every chunk exactly as the existing
header fields are - ingest replaces every chunk of a source_file in one
transaction, so they cannot drift within a document.
complete() deliberately still requires only doc_number, revision and
effective_date. A missing title makes an answer less useful; a wrong revision
sends somebody to the wrong document. --assume-yes must keep refusing on the
second and tolerate the first.
controlled_copy_location is NOT in the schema. It is a site fact, identical on
every row, and the one field where an invented value sends a person to a place
that does not exist. It is CONTROLLED_COPY_LOCATION in api.env, defaulting to
a string that names who to ask.
The authorising-role pattern requires the colon: without it the lazy gap
swallowed the field name and captured "role: Station Maintenance Supervisor"
as the value, which the first run caught.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file says "every key, no values" at the top and was not that, which cost
real time twice on lin001 in one day.
- CUBEJS_DB_* was absent entirely, while ai-compose.yml states that Cube's
database settings come from api.env. Deploying Cube meant reconstructing
what it needed from compose comments and db/003_roles.sql. Added, with the
pg-ai.env names its credentials come from, and a note that Phase 4 turns
the block into an mssql connection against imh.
Also recorded NEGATIVELY: not CUBEJS_EXT_DB_*. Cube v1 refuses Postgres as
an external pre-aggregation store, so someone reading the older compose
file will otherwise try to supply keys for a setting that must not exist.
- NO_LLM_STUB, which is new and defaults to false here for the same reason it
defaults to false in config.py.
- The Langfuse keys were listed but not explained, and every way of getting
them wrong is SILENT:
absent -> _langfuse() returns None, every question untraced
mismatched pair -> a client is built, Langfuse rejects it, and main.py
swallows the exception by design
Neither logs anything, in an answer path that is deliberately built never
to break on observability. The symptom is identical - no traces - so
correcting one cause while the other is still present looks like no
progress at all. That is exactly what happened: a placeholder secret was
pasted alongside a public key from a different pair, and the fix looked
like it had not worked.
So the file now says: they are PROJECT keys from the UI, not the server's
own SALT/NEXTAUTH_SECRET in langfuse.env; they must be a matched pair; the
secret is shown once and stored hashed, so it cannot be read back; and
traces must be confirmed as ARRIVING rather than inferred from config.
No secrets here, including the masked tail of a real key - the example suffix
is invented.
The Langfuse fix itself is not in this commit and cannot be: it was two lines
in ~/ai/api.env, which .gitignore excludes on purpose.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Build spec and host brief carried in from C:\Claude and WRPS/02-env; the
plant model (equipment, tags, alarm bitmask, enums, unit conversions) is
derived from WRPS/04-plc/register-map.csv, WRPS/05-scada/modbus/scada-points.csv
and WRPS-CTL-003.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>