With the seed rekeyed, a question asking after PS_STN_WET_WELL_LEVEL now
matches nothing - and the assistant answers "no records found". An
operator reads that as "the plant recorded nothing", not as "you asked
with a name this system retired". That is precisely the confusion the four
namespaces exist to prevent, and an empty result is the wrong shape of
answer for it.
equipment.resolve() now rejects those names up front, raising
ContractViolation, which main.py already turns into an error rather than
an answer. The message says what the name was, why it is not a tag, and
what to use instead.
The four Modbus poll groups keep the prefix legitimately - they are
groups, not names, and nothing resolves an operator term onto one - so
PS_STATUS_BITS, PS_PUBLISHED, PS_SETPOINTS and PS_SIM_CONTROL pass.
Scope, deliberately small: this covers equipment.resolve(), the path an
operator's words take. Calling metrics directly with a retired id is not
covered. For a demo that is the right trade; for production it is not.
Verified by lifting the guard's own source out of the file and exercising
it - upper case, lower case, surrounding whitespace, the _SP variant, all
four poll groups, and the item, instrument and plain-English forms. The
module itself could not be imported here: no psycopg on this machine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
45 of the 65 rows in db/seed/tags.csv had a PS_* name as their PRIMARY
KEY, and historian_items.csv, alarm_bits.csv, the fixtures, two Cube
models and the exam all referenced them. They are gone. The tag seed is
now keyed on the CI Server item for everything the historian carries -
AID.WRPS.STN.LEVEL - and on the instrument tag for the 20 field devices
that never reach SCADA.
gen_historian_items.py reads db/seed/scada-source/ by default, so it runs
for anyone with a clone: --wrps is now --source. The tag match is the item
name itself, an identity lookup, and the TAG_FOR_ITEM special case is
deleted - all 49 item names are unique, which the old point names were
not. scada_point in the output is replaced by ci_station, ci_point and
poll_group; 001_schema.sql and deploy.sh's upsert follow.
Regenerated, and it comes out the same shape it went in: 49 items, 45
answerable, 4 deliberately excluded, three groups at 5 s, 30 s and on
change. Every historian_items.tag_id and alarm_bits.tag_id resolves to a
tags.csv row. No duplicate keys.
THE ONE NAME THAT WAS AMBIGUOUS, AND NEARLY COST US
PS_STN_HIGH_LEVEL_ALARM named two different things in the old delivery:
the high level alarm STATUS BIT on coil 10, and the alarm SETPOINT on
holding register 1032. Building the rename map from that file kept
whichever came last, so the status bit was silently renamed onto the
setpoint. check_mapping() refused to write and named the item that no
longer resolved - which is the only reason this is a paragraph in a commit
message rather than a defect.
Had it gone through, alarm bit 0 - wet well high level - would have
pointed at the setpoint. "How many high level alarms last week" would have
counted setpoint changes and returned a small, plausible, confident,
wrong number. That check exists because the same ambiguity caused the
first Phase 5 finding in August. It has now bitten twice.
NOT YET VERIFIED: pytest api/tests could not be run here - this machine
has neither fastapi nor psycopg. The tests are unchanged and reference no
PS_ name, but they have not been run. pg-ai on lin001 still holds the old
keys and must be reloaded, Cube pre-aggregations rebuilt, and the Phase 1
gate re-run. eval/testset.jsonl changed, so the 78-case exam - never yet
run - should be run after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reverts 038cdc5. The source itself was reachable and the demo data was
sound, but the design questions underneath it were not settled: whether it
belongs in Cube or in plain SQL once Cube is repointed at imh at Phase 4,
whether it is append-only, how an entry is authored and authenticated, and
what a real query window over it looks like. Better out than half-committed.
Kept from 038cdc5:
H02 the 'last 3 days' wording. That change was asked for on its own and
has nothing to do with the shift log. It still fails, on the
_contains_quantity false positive.
Restored:
N04 back to the shift log question. With no such source it tests what it
was written to test again: "no such source" is not "no records found".
S01, S02 removed with the feature.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A shift log is continuously updated, which rules out both stores we already
have. Not doc_chunks: that store is built on doc_number + revision +
effective_date and nothing in it is citable until a human confirms the header,
so a record changing every shift would wear out the gate that makes procedure
citations trustworthy. Not the historian: we hold read-only on imh and may not
write to it at all, it is keyed on the CI Server item and a log entry has no
item, and its seven-day retention would expire the one source that could
outlive it.
So a table in pg-ai, read on the historical lane because that lane's contract
already fits it - a time window, rows, and "no records found" when there are
none. Entries are rows, NOT citations: a citation is a controlled document with
a confirmed revision and an operator's note is not one however true it is. The
Citation contract is untouched.
Routed on the question text rather than a classifier label. Adding a sixth
class to the five-way classifier - the most safety-relevant component in the
stack - to reach one demo source would be a poor trade. Case S01 pins the
trigger.
Demo entries are anchored to the start of today in site local time, so a
re-run always lands them inside the rolling seven days and the shift label
always matches the clock. The alarm fixtures are absolute and have gone stale;
this cannot. Future-dated entries are dropped, and the load asserts the window
and the count rather than trusting them.
Eval, 78 -> 80 cases:
S01, S02 the shift log, whole and filtered by equipment
N04 repointed at the environmental discharge licence. The old wording
asked about the shift log, which now exists, so it had stopped
testing "no such source" - a different answer from "no records
found", and the distinction is the point of the case.
H02 window changed to a relative one. It still fails, on a false
positive in _contains_quantity: "the last 3 days" reads as a
fabricated figure, so the correct zero-row answer is rejected.
Left for its own change.
80 cases: 97.5% overall, 100% classification, p95 5257 ms. One contract
violation (H02), so the Phase 8 gate is still not met.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The query window is a rolling seven days by design, and stays that way against
the real SQL historian - it is not parsed out of the question. Two things I
wrote assumed otherwise.
H29 was wrong in two ways. It demanded the words "retention" and "seven days",
and it BANNED "no records found" - which contradicts CLAUDE.md, where that is
the required wording for zero rows. With a fixed one-week window June genuinely
has zero rows in what was queried, so the phrase is correct rather than evasive.
It also described the missing piece as question-window parsing, which is not a
gap but the design.
Rewritten to pin the risk that actually exists: SUBSTITUTION. A question naming
June must never be answered with this week's figure wearing June's label.
Answering "there were 14" would be exactly that, and nothing downstream could
catch it. The observed answer refuses the substitution and states the window it
used, so the case now passes on its merits rather than being red by default.
must_not is the only half run_eval enforces, so the banned phrases are ones
that appear only on a substitution.
outside_retention is reframed as what it is: a GUARD, always false while every
caller asks for seven days, there so a caller who later passes a longer window
cannot get an empty result that reads as "nothing happened".
REQUESTS.md and the removal guide now say that extending retention on its own
is inert - the window would still ask for a week of a longer history. Both
halves are needed, or neither.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three open Phase 5 findings were one defect: the stand-in was keyed on
CI Server POINT names (PS_STN_WET_WELL_LEVEL) when the historian is keyed on
CI Server ITEM names (AID.WRPS.STN.LEVEL). Modbus carries register numbers,
not names, so those two layers are free to differ - and do. Reconciling
against the register map, as planned, would only have proved the first three
namespaces agreed with each other.
Rebuilt from WRPS/05-scada/modbus, so item names, sample rates, retention and
timestamp semantics come from the machine rather than from a guess.
(a) Level tag does not join. PS_STN_WET_WELL_LEVEL becomes a tag row in its
own right; LIT-101 is marked NOT HISTORISED - a field input on %IW0 that
never reaches SCADA. It was the only seed row carrying two addresses.
public.historian_items holds the item-to-tag mapping, generated by
scripts/gen_historian_items.py and enforced non-empty at generate, at
deploy and at verify.
(b) first_alarm/last_alarm returned UTC. Converted inside the measure, so it
stays in Cube and happens once. Aggregate first, convert after - the other
order picks the wrong row across a DST fall-back. Returned as a formatted
string with a companion site_timezone measure. Storage being UTC is now
confirmed, not assumed: all 49 points carry TIME_ZONE "Date+time GMT" and
every history group CORRECT_DAYLIGHT=0. This answers Phase 4 task 4.
(c) High level alarm filed against the wrong equipment. Both sides were right
about different things; the defect was asserting equipment twice. The
history now carries no equipment column at all - faithful, since CI
Server's section tree stops at the station and three pumps. Equipment is
reached bit -> tag -> equipment via public.alarm_bits.
Alarms are derived, not stored: CI Server's ALARM_HISTORY group is empty
because every item imports with alarming off. Decomposing the alarm word needs
no configuration that does not exist.
Three things the SCADA config changed that were never filed as faults:
- retention is 7 days, not 30. The advisory path was reporting a month of
evidence drawn from a week of data
- the analogue rate is 5 s, not 60. Two measures multiplied sample counts by
a hardcoded 60 - a twelvefold overstatement that read as plausible
- the deadband warning in process_values.yml was wrong and was steering
people away from the correct measure
db/002_fixtures.sql now asserts its own counts at load and cross-checks the
alarm derivation against two independent signals. Those prove the pipeline,
not the plant.
db/README-standin-historian.md documents removal: the seam between generation
and contract, and twelve assumptions about imh that are NOT confirmed. Two of
them fail silently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two retrieval faults, both only visible once a real model ran.
find_procedure ranked a procedure's chunks by similarity to the question. For
"how do I lift the interlock on Pump 02" the closest chunks ARE the step list -
so the branch whose entire purpose is not reproducing steps was handing the
answer writer nothing but steps, while omitting the header block carrying the
title and the authorising role. The model was being asked for a title it had
never been shown, and returned "".
Once the document is identified, WHICH document it is settles what to send:
the header and the prerequisites, in document order, never the steps.
STEP_SECTION_RE is a second line behind ProceduralAnswer's instruction-language
check, not a replacement for it - the contract still rejects instruction
language whatever arrives here. This removes the temptation rather than relying
on catching it.
Separately, rerank did 0.75 * chunk.similarity where similarity is NULL for a
chunk ingested with --no-embed: `1 - (NULL <=> vec)` is NULL, so it raised
TypeError and 500'd the whole question rather than ranking that chunk last. It
now degrades to the lexical half - an unembedded chunk is still findable, just
not by meaning - and Chunk.similarity is typed honestly as float | None.
find_procedure_lexical takes the same identify-then-expand shape, so the stub
keeps testing the shape it always did.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Every Historical and Advisory answer stated a window in AEST and queried one
shifted by ten hours.
rolling_window() builds its boundary strings in SITE_TIMEZONE - that is the
whole point of it, and its docstring says so. metrics.run() then posted the
query to Cube with no timezone at all, and Cube defaults to UTC. So
"2026-08-14T15:22:13" meant 15:22 Sydney to the code that produced it and 15:22
UTC to the engine that ran it, and MetricResult.time_window reported
SITE_TIMEZONE from config rather than whatever the query actually used, so the
two could not disagree visibly.
Measured on the fixtures, same dateRange, one field changed:
timezone UTC 8019 samples
timezone Australia/Sydney 8619 samples
600 samples. One per minute, ten hours, exactly the offset.
Nothing about the answer looked wrong. The prose was right, the count was a
real count, the window description was correctly formatted and correctly named
AEST. It was only visible by reading the Cube query in the UI's "show working"
panel - which is an argument for that panel existing, and an argument for
looking at the thing in a browser rather than trusting curl against the API.
- check_cube_query() now takes site_timezone and pins it onto the query, at
the single point every Cube query passes through. Per-query-builder is the
wrong place: "remember to set the timezone" is not a control, and this
defect is what forgetting looks like. An explicit timezone already on the
query is left alone.
- time_window now reports capped["timezone"] - the timezone the query ran in,
not the one it should have run in.
An unpinned timezone belongs in the same guardrail as an unpinned date range,
and for the same reason: both make an answer unreproducible. The difference is
that an unpinned date range is obvious in the query and an unpinned timezone
is invisible.
eval case H28 records it. Two unit tests: the timezone is pinned, and an
explicit one is not overridden.
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>