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>
129 lines
4.6 KiB
Python
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),
|
|
)
|