yau-plant-assistant/api/tools/shiftlog.py
Claude 038cdc510c Add the operator shift log as a third evidence source
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>
2026-09-01 13:01:31 +10:00

129 lines
4.6 KiB
Python

"""The operator shift log: the third evidence source.
Not the historian and not a controlled document - db/008_shift_log.sql says why
at length. What matters here is the consequence: entries are ROWS, never
citations. A citation is a controlled document with a confirmed revision, and
an operator's note is not one however true it is.
WHY THIS IS PLAIN SQL AND NOT CUBE
Cube is the only path to the HISTORIAN, and for good reasons that all concern
imh being a live system we hold read-only on. This table is ours, it is small,
and it is not modelled in Cube. Same pattern as tools/equipment.py and
tools/retrieval.py, which query pg-ai directly.
TIMEZONE: storage is UTC and the conversion to SITE_TIMEZONE happens once, in
the query below. That is the same rule Cube follows for the historian - convert
exactly once, on the way out, and never in a prompt.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from zoneinfo import ZoneInfo
import psycopg
from psycopg.rows import dict_row
from config import settings
log = logging.getLogger("tools.shiftlog")
# The shift log is asked for by name. This is a lexical trigger rather than a
# classifier label on purpose: adding a sixth class to a five-way classifier -
# the most safety-relevant component in the stack - to reach a demo table would
# be a poor trade. The cost is that this only fires when the operator uses one
# of these words, which is exactly what the eval case pins.
_MENTIONS = re.compile(
r"\b(shift\s*log|shift\s*notes?|operator\s*log|log\s*book|logbook|handover)\b",
re.IGNORECASE,
)
@dataclass
class ShiftLogResult:
rows: list[dict[str, Any]]
row_count: int
time_window: dict[str, str]
# TRUE when any entry returned is demo fiction. Carried out to the answer
# as used_fixture_data, for the same reason the historian fixtures do it:
# an entry nobody wrote must not read like one somebody did.
used_demo_data: bool
def mentions_shift_log(question: str) -> bool:
return _MENTIONS.search(question) is not None
def _connect() -> psycopg.Connection:
cfg = settings()
return psycopg.connect(
cfg.dsn(),
row_factory=dict_row,
application_name="ai-api",
connect_timeout=5,
)
def entries(
*, days: int, equipment_id: str | None = None, conn: psycopg.Connection | None = None
) -> ShiftLogResult:
"""Shift log entries over a rolling N x 24 h window, oldest first.
The window matches the historical lane's window rather than anything about
this table - nothing here expires. When the query window becomes variable
(REQUESTS.md), this source can reach further back than the historian can.
"""
cfg = settings()
tz = ZoneInfo(cfg.site_timezone)
end = datetime.now(timezone.utc)
start = end - timedelta(days=days)
local_end = end.astimezone(tz)
owned = conn is None
conn = conn or _connect()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT to_char(entry_time AT TIME ZONE %(tz)s,
'YYYY-MM-DD HH24:MI') AS entry_time_local,
shift, author, equipment_id, entry_text, is_demo
FROM shift_log
WHERE entry_time >= %(start)s
AND entry_time <= %(end)s
-- Cast is required: an untyped NULL parameter used on both
-- sides of an OR leaves Postgres unable to infer the type.
AND (%(equipment_id)s::text IS NULL
OR equipment_id = %(equipment_id)s::text)
ORDER BY entry_time
""",
{"tz": cfg.site_timezone, "start": start, "end": end,
"equipment_id": equipment_id},
)
rows = [dict(r) for r in cur.fetchall()]
finally:
if owned:
conn.close()
log.info("shift_log: %d entries over %d days (equipment=%s)",
len(rows), days, equipment_id or "any")
return ShiftLogResult(
rows=rows,
row_count=len(rows),
time_window={
"start": start.astimezone(tz).strftime("%Y-%m-%dT%H:%M:%S"),
"end": local_end.strftime("%Y-%m-%dT%H:%M:%S"),
"timezone": cfg.site_timezone,
"description": (
f"rolling {days} days to "
f"{local_end.strftime('%Y-%m-%d %H:%M')} {local_end.tzname()}"
),
},
used_demo_data=any(r["is_demo"] for r in rows),
)