yau-plant-assistant/api/tests/test_model_contract_shapes.py
Claude 40f32087a9 Take the facts back off the model
Four faults, all surfaced within an hour of the first live Azure OpenAI call
on 2026-08-27, all invisible under NO_LLM_STUB because the stub supplied the
very fields that turned out to be missing.

1. The classifier few-shot showed eight replies of {"question_class": ...}
   alone. A few-shot reply is a shape the model copies, so it omitted
   confidence, which defaulted to 0.0, fell below the 0.7 threshold, and EVERY
   non-procedural question downgraded to UNCLEAR. The replies now carry the
   complete payload the system prompt asks for. Confidences are varied and the
   traps carry alternatives: a constant teaches the model to emit that
   constant, and the tie rule in apply_safety_rules only has something to work
   with if the runners-up are populated.

2. procedure{} was the one part of the procedural payload not assembled from
   evidence, contrary to _assemble's own stated rule. The model returned
   effective_date "" - neither a date nor None - so ProceduralAnswer rejected
   the answer, the single regeneration failed identically, and every procedural
   question returned 422.

3. title and authorising_role came back "" for the same reason: the model was
   asked for header fields it had never been shown.

4. documented_limits[].citation arrived as the string "WRPS-DEMO-003, Section
   4" where a Citation was required, because the schema hint said only
   "documented_limits": [] and told the model nothing about the shape.

procedure_identity now takes no `generated` argument at all: there is no path
by which a model can name a revision an operator does not hold. documented_
limits attaches the real Citation by matching source_file against what was
actually retrieved, and DROPS a limit matching nothing - a limit carries the
authority of the document behind it, and misattributing one is worse than
omitting it.

Both live in contracts.py rather than agent.py because they are contract
rules, and because agent.py imports langgraph, which would make the test suite
unrunnable on a bare checkout.

The prompt also now separates two things it was conflating: retrieval
returning nothing (say so and stop) from retrieval returning a document marked
draft, demo or superseded (identify it, quote it, and state the marking).
Including the header chunk made the model read "NOT A CONTROLLED DOCUMENT" and
answer "no controlled procedure was retrieved" while citing one. The marking is
information the operator needs, not a reason to withhold what was found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:54:29 +10:00

161 lines
6.2 KiB
Python

"""Three defects that only appear once a real model runs, pinned in Python.
All three were invisible under NO_LLM_STUB and surfaced within minutes of the
first live Azure OpenAI call on 2026-08-27. None of them needs an API key or a
database to test, which is the whole argument for putting them here.
"""
import json
import pytest
from classifier import FEW_SHOT
from config import settings
from contracts import QuestionClass, documented_limits, procedure_identity
# ---------------------------------------------------------------------------
# 1. The classifier few-shot must model the WHOLE reply shape
# ---------------------------------------------------------------------------
# Read by classify(); an absent key silently becomes a default, and for
# `confidence` that default is 0.0 - below every sane threshold.
REQUIRED_KEYS = {"question_class", "confidence", "alternatives",
"entities", "missing_context"}
def test_every_few_shot_reply_carries_the_full_payload():
"""The bug: replies were {"question_class": ...} alone.
A few-shot reply is a shape the model copies. Omitting `confidence` taught
it to omit `confidence`, so every question scored 0.0 and downgraded to
UNCLEAR.
"""
for question, reply in FEW_SHOT:
missing = REQUIRED_KEYS - reply.keys()
assert not missing, f"{question!r} omits {sorted(missing)}"
def test_few_shot_confidences_are_usable_and_not_constant():
confidences = [r["confidence"] for _, r in FEW_SHOT]
assert all(0.0 < c <= 1.0 for c in confidences)
# A constant teaches the model to emit that constant.
assert len(set(confidences)) > 1
def test_few_shot_replies_are_json_serialisable():
for _, reply in FEW_SHOT:
json.loads(json.dumps(reply))
def test_few_shot_classes_are_real_classes():
for _, reply in FEW_SHOT:
QuestionClass(reply["question_class"])
for name in reply["alternatives"]:
QuestionClass(name)
# ---------------------------------------------------------------------------
# 3. Procedure identity comes from evidence, never from the model
# ---------------------------------------------------------------------------
CITATION = {
"doc_number": "WRPS-DEMO-001",
"title": "Temporary Bypass of Pump Motor Protection Interlock",
"revision": "0",
"effective_date": "2026-01-01",
"page": 1,
"section_title": "2. Prerequisites",
"source_file": "procedures/DEMO-interlock-bypass.md",
"superseded": False,
}
# authorising_role is denormalised onto every chunk of the document by ingest.
CHUNKS = [{"authorising_role": "Station Maintenance Supervisor"}]
def test_identity_ignores_the_model_entirely():
"""The wrong-revision hazard. procedure_identity takes no `generated`
argument at all now: there is no path by which a model can name a revision
an operator does not hold."""
identity = procedure_identity({"citations": [CITATION], "chunks": CHUNKS})
assert identity["doc_number"] == "WRPS-DEMO-001"
assert identity["revision"] == "0"
# The bug: the model returned "", which is neither a date nor None, so
# ProceduralAnswer rejected it twice and /ask returned 422.
assert identity["effective_date"] == "2026-01-01"
def test_title_comes_from_the_confirmed_header():
"""Was "" whenever the header chunk was not retrieved - which under the old
similarity-ranked find_procedure() was most of the time."""
identity = procedure_identity({"citations": [CITATION], "chunks": CHUNKS})
assert identity["title"] == "Temporary Bypass of Pump Motor Protection Interlock"
def test_authorising_role_comes_from_the_chunks():
identity = procedure_identity({"citations": [CITATION], "chunks": CHUNKS})
assert identity["authorising_role"] == "Station Maintenance Supervisor"
def test_controlled_copy_location_comes_from_settings():
"""Never the model's: an invented location sends somebody to a place that
does not exist."""
identity = procedure_identity({"citations": [CITATION], "chunks": CHUNKS})
assert identity["controlled_copy_location"] == settings().controlled_copy_location
assert identity["controlled_copy_location"]
def test_title_falls_back_to_source_file_on_a_pre_007_row():
stale = {**CITATION, "title": None}
identity = procedure_identity({"citations": [stale], "chunks": [{}]})
assert identity["title"] == "procedures/DEMO-interlock-bypass.md"
def test_no_evidence_means_no_procedure():
"""Never assert a procedure with nothing retrieved behind it."""
assert procedure_identity({"citations": [], "chunks": []}) is None
# ---------------------------------------------------------------------------
# 4. Documented limits are cited from evidence, never by the model
# ---------------------------------------------------------------------------
DESIGN_CITATION = {
"doc_number": "WRPS-DEMO-003",
"title": "Design Basis",
"revision": "0",
"effective_date": "2026-01-01",
"page": 1,
"section_title": "4. Hydraulics",
"source_file": "design/DEMO-design-basis.md",
"superseded": False,
}
def test_limit_gets_the_real_citation_object():
"""The bug: the model returned citation as the string
"WRPS-DEMO-003, Section 4" where a Citation was required, so every advisory
answer failed its contract twice and /ask returned 422."""
generated = {"documented_limits": [
{"description": "Spill weir crest", "value": 6000, "unit": "mm",
"source_file": "design/DEMO-design-basis.md",
"citation": "WRPS-DEMO-003, Section 4"},
]}
limits = documented_limits({"citations": [DESIGN_CITATION]}, generated)
assert limits[0]["citation"] == DESIGN_CITATION
assert limits[0]["value"] == 6000
assert limits[0]["unit"] == "mm"
def test_limit_with_an_unretrieved_source_is_dropped():
"""Never attach a limit to the nearest citation. A limit carries the
authority of the document behind it."""
generated = {"documented_limits": [
{"description": "Invented limit", "value": 42,
"source_file": "design/NOT-RETRIEVED.md"},
]}
assert documented_limits({"citations": [DESIGN_CITATION]}, generated) == []
def test_no_limits_is_an_empty_list_not_none():
assert documented_limits({"citations": [DESIGN_CITATION]}, {}) == []