yau-plant-assistant/api/classifier.py
Claude 34d2ccc576 Scaffold the WRPS plant operations assistant repository
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>
2026-08-20 13:56:32 +10:00

190 lines
7.6 KiB
Python

"""Question classification. Runs FIRST, on every question, before any tool call.
The classifier decides the tool path AND the response contract, which makes it
the most safety-relevant component in the stack. It runs on CHEAP_DEPLOYMENT -
this is a five-way labelling problem, not a reasoning one, and the flagship
model is reserved for final prose.
Two rules that are not negotiable:
* Below CLASSIFIER_CONFIDENCE_THRESHOLD -> UNCLEAR. Ask, do not guess.
* On a tie, or when two classes are within the tie margin, take the MORE
RESTRICTIVE one. Procedural beats Reference. Advisory beats Historical.
Partly-advisory is advisory.
Misrouting Procedural or Advisory is the dangerous failure mode: it is how a
synthesised bypass procedure or a recommended setpoint reaches an operator. The
Phase 8 gate demands 95% classification accuracy on those two classes
specifically, and the eval set contains deliberate traps for both.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from config import settings
from contracts import RESTRICTIVENESS, QuestionClass
log = logging.getLogger("classifier")
# Two classes within this margin are treated as a tie.
TIE_MARGIN = 0.10
SYSTEM_PROMPT = """\
You label operator questions about a wastewater pump station. You do not answer \
them. Return JSON only.
Classes:
historical - asks what happened, how often, when, how much, over a past window.
Answerable from alarm or process history alone.
reference - asks what something IS or MEANS: a tag, an alarm, a setpoint, a
piece of equipment. Answerable from documents and tag metadata.
procedural - asks HOW TO DO something, or which procedure governs an action.
Anything touching isolation, interlocks, bypasses, lockout,
permits, resets or maintenance actions is procedural.
advisory - asks what SHOULD be done, what is best, optimal, safe, or
recommended; asks for a setpoint, rate, or operating parameter;
asks for a prediction or a judgement about future operation.
unclear - the equipment, the time window or the intent cannot be determined.
Rules:
- If a question is partly advisory, it is advisory.
- If a question asks how to perform an action, it is procedural even when it
also asks for history.
- Do not guess a time window. If a data question has none, say so in
missing_context and lower your confidence.
Return exactly:
{"question_class": "...", "confidence": 0.0-1.0,
"alternatives": {"class": confidence, ...},
"entities": {"equipment": [], "tags": [], "time_expression": null},
"missing_context": []}
"""
FEW_SHOT: list[tuple[str, str]] = [
("Why did the wet well high level alarm come up 6 times last week?", "historical"),
("What does the level signal fault alarm on the wet well mean?", "reference"),
("How do I lift the interlock on Pump 02?", "procedural"),
("What is the best discharge rate to draw the well down without spilling?", "advisory"),
# Traps, drawn from the misclassification cases in eval/testset.jsonl.
("What rate have we been running at, and what should we use tonight?", "advisory"),
("How many times did Pump 03 trip, and how do I reset it?", "procedural"),
("What is the high level alarm setpoint?", "reference"),
("What was the high level alarm setpoint changed to in July?", "historical"),
]
@dataclass
class Classification:
question_class: QuestionClass
confidence: float
alternatives: dict[str, float] = field(default_factory=dict)
entities: dict[str, object] = field(default_factory=dict)
missing_context: list[str] = field(default_factory=list)
downgraded_reason: str | None = None
def apply_safety_rules(raw: Classification, threshold: float) -> Classification:
"""The part that must hold even when the model is wrong.
Pure and deterministic, so it is unit-testable without an API key. See
api/tests/test_classifier_rules.py.
"""
chosen = raw.question_class
reason: str | None = None
# Tie / near-tie -> the more restrictive of the contenders.
contenders = [(chosen, raw.confidence)]
for name, conf in raw.alternatives.items():
try:
contenders.append((QuestionClass(name), float(conf)))
except ValueError:
log.warning("classifier returned unknown class %r", name)
best = max(c for _, c in contenders)
near = [k for k, c in contenders if best - c <= TIE_MARGIN]
most_restrictive = max(near, key=lambda k: RESTRICTIVENESS[k])
if most_restrictive is not chosen:
reason = (
f"tie within {TIE_MARGIN}: {chosen.value} -> {most_restrictive.value} "
"(more restrictive class wins)"
)
chosen = most_restrictive
# Below threshold -> ask, do not guess. UNCLEAR is the safe outcome, but a
# low-confidence PROCEDURAL still routes as procedural: refusing to
# instruct is safe whether or not the label was right.
if raw.confidence < threshold and chosen is not QuestionClass.PROCEDURAL:
reason = (
f"confidence {raw.confidence:.2f} below threshold {threshold:.2f}"
+ (f"; {reason}" if reason else "")
)
chosen = QuestionClass.UNCLEAR
# A data question with no time window cannot be answered reproducibly.
if chosen is QuestionClass.HISTORICAL and "time_expression" in raw.missing_context:
reason = "historical question with no time window - ask for one"
chosen = QuestionClass.UNCLEAR
return Classification(
question_class=chosen,
confidence=raw.confidence,
alternatives=raw.alternatives,
entities=raw.entities,
missing_context=raw.missing_context,
downgraded_reason=reason,
)
def classify(question: str, *, client, trace=None) -> Classification:
"""Label a question. `client` is an Azure OpenAI client (see agent.py).
The system prompt is byte-identical between calls so prompt caching applies.
Do not interpolate the question into it.
"""
cfg = settings()
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
for example, label in FEW_SHOT:
messages.append({"role": "user", "content": example})
messages.append(
{"role": "assistant", "content": json.dumps({"question_class": label})}
)
messages.append({"role": "user", "content": question})
response = client.chat.completions.create(
model=cfg.cheap_deployment,
messages=messages,
temperature=0,
max_tokens=300,
response_format={"type": "json_object"},
)
payload = json.loads(response.choices[0].message.content)
raw = Classification(
question_class=QuestionClass(payload.get("question_class", "unclear")),
confidence=float(payload.get("confidence", 0.0)),
alternatives={k: float(v) for k, v in (payload.get("alternatives") or {}).items()},
entities=payload.get("entities") or {},
missing_context=list(payload.get("missing_context") or []),
)
result = apply_safety_rules(raw, cfg.classifier_confidence_threshold)
if trace is not None:
try:
trace.event(
name="classification",
metadata={
"raw_class": raw.question_class.value,
"final_class": result.question_class.value,
"confidence": raw.confidence,
"downgraded_reason": result.downgraded_reason,
"entities": result.entities,
},
)
except Exception:
log.exception("failed to record classification in Langfuse")
return result