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>
268 lines
11 KiB
Python
268 lines
11 KiB
Python
"""No-LLM stub mode. OFF by default, and it must stay that way.
|
|
|
|
WHY THIS EXISTS
|
|
---------------
|
|
Azure OpenAI access is pending, and so is `imh`. Without a model there is no
|
|
classifier and no prose, so `POST /ask` cannot return anything at all - which
|
|
means the entire chain either side of the model is unproven: the browser, Caddy,
|
|
Authelia, the API, entity resolution, Cube, retrieval, the contracts, the
|
|
banners, the error paths. That is a lot of untested surface to leave until the
|
|
day the key arrives, and it is all testable now.
|
|
|
|
So this module substitutes the two steps that need a model:
|
|
|
|
* classification -> the class is supplied by the caller (a dropdown in the
|
|
UI). Not guessed by keywords: a crude keyword classifier
|
|
would produce a *plausible* label, and a plausible wrong
|
|
label is the failure this system is built to avoid.
|
|
Choosing explicitly is honest, and it lets each branch be
|
|
driven deliberately.
|
|
* prose -> a fixed placeholder string per class. Deterministic, and
|
|
written to satisfy the contract for its class.
|
|
|
|
WHAT IT PROVES
|
|
--------------
|
|
The transport chain end to end. Entity resolution. Cube queries and their
|
|
numbers. Retrieval plumbing and citation assembly. The fixture banner. The
|
|
scope banners. Contract validation - including its failures, because a stub
|
|
payload goes through `enforce_contract()` exactly like a generated one and a
|
|
422 is a real result, not a bug in the stub.
|
|
|
|
WHAT IT DOES NOT PROVE
|
|
----------------------
|
|
Anything about the model. Whether the classifier would label the question
|
|
correctly - the whole point is that a human labelled it. Whether retrieval finds
|
|
the RIGHT chunk: stub mode retrieves lexically because embedding the question
|
|
needs the model, and lexical hits are not vector hits. And no prose quality,
|
|
because there is no prose.
|
|
|
|
It also cannot produce `prerequisites_verbatim` for a Procedural answer. Pulling
|
|
the prerequisites out of a retrieved chunk is an extraction task, and doing it
|
|
with a regex would be exactly the "synthesised from fragments" failure the
|
|
Procedural contract exists to prevent. So the list comes back empty and the
|
|
answer says so.
|
|
|
|
THE SAFETY RULES STILL RUN
|
|
--------------------------
|
|
`apply_safety_rules()` still runs over the forced classification. Contracts are
|
|
still validated after assembly and before returning. Retrieval still filters
|
|
superseded revisions. Nothing here is a bypass - it is the same pipeline with
|
|
the model-shaped holes filled by constants.
|
|
|
|
TURNING IT OFF AGAIN
|
|
--------------------
|
|
`NO_LLM_STUB=false` in ~/ai/api.env, and restart ai-api. Every answer produced
|
|
in this mode carries `stub_mode: true` and a banner in the same place, and for
|
|
the same reason, as the fixture banner: an answer nobody generated is otherwise
|
|
indistinguishable from one that was.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from classifier import Classification, apply_safety_rules
|
|
from config import settings
|
|
from contracts import QuestionClass
|
|
|
|
log = logging.getLogger("stub")
|
|
|
|
BANNER = (
|
|
"NO-LLM STUB MODE. No language model was called. The class was chosen by "
|
|
"hand, retrieval was lexical rather than semantic, and this text is a fixed "
|
|
"placeholder - not a generated answer. The figures, citations and document "
|
|
"identities below are real query results."
|
|
)
|
|
|
|
|
|
def enabled() -> bool:
|
|
return settings().no_llm_stub
|
|
|
|
|
|
def classify(question: str, forced_class: str | None) -> Classification:
|
|
"""Take the class from the caller and put it through the real safety rules.
|
|
|
|
Confidence is 1.0 because a person chose it, not because anything was
|
|
inferred. The rules still run: a forced HISTORICAL with no time window
|
|
still becomes UNCLEAR, which is worth seeing.
|
|
"""
|
|
try:
|
|
klass = QuestionClass(forced_class) if forced_class else QuestionClass.UNCLEAR
|
|
except ValueError:
|
|
log.warning("unknown forced class %r - falling back to unclear", forced_class)
|
|
klass = QuestionClass.UNCLEAR
|
|
|
|
raw = Classification(
|
|
question_class=klass,
|
|
confidence=1.0,
|
|
alternatives={},
|
|
entities={},
|
|
missing_context=[] if forced_class else ["what you are asking about"],
|
|
)
|
|
result = apply_safety_rules(raw, settings().classifier_confidence_threshold)
|
|
result.downgraded_reason = (
|
|
(result.downgraded_reason + "; ") if result.downgraded_reason else ""
|
|
) + "class supplied by the caller - NO_LLM_STUB is on, nothing was classified"
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# One payload builder per class. Each returns what the generate() step would
|
|
# have returned, minus the prose. The factual fields are attached from evidence
|
|
# by the caller in agent.py, exactly as they are for a generated answer.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _historical(question: str, evidence: dict[str, Any]) -> dict[str, Any]:
|
|
row_count = evidence.get("row_count", 0)
|
|
window = evidence.get("time_window", {}).get("description", "the window queried")
|
|
if row_count == 0:
|
|
# Must say so, and must not state a quantity. The contract checks both.
|
|
answer = (
|
|
f"{BANNER} The query returned no records for {window}."
|
|
)
|
|
else:
|
|
# Row counts and figures live in the structured fields, which the UI
|
|
# renders. Keeping them out of the prose keeps this string honest -
|
|
# it is not a summary of anything.
|
|
answer = (
|
|
f"{BANNER} The query ran and returned rows for {window}. "
|
|
"The figures are in the rows and query fields, not in this text."
|
|
)
|
|
return {"answer": answer}
|
|
|
|
|
|
def _reference(question: str, evidence: dict[str, Any]) -> dict[str, Any]:
|
|
if not evidence.get("citations"):
|
|
answer = (
|
|
f"{BANNER} No documents in the active set matched this question."
|
|
)
|
|
else:
|
|
answer = (
|
|
f"{BANNER} The documents below matched lexically. Whether they "
|
|
"actually answer the question is not established - that is the "
|
|
"judgement a language model would have made."
|
|
)
|
|
return {"answer": answer}
|
|
|
|
|
|
def _procedural(question: str, evidence: dict[str, Any]) -> dict[str, Any]:
|
|
"""Identity only. No prerequisites, and emphatically no steps.
|
|
|
|
The procedure identity is assembled from the retrieved chunk's own
|
|
metadata - document number, revision, effective date - which is database
|
|
content, not generated text. That is the part of a Procedural answer that
|
|
matters most, and it is the part that needs no model at all.
|
|
"""
|
|
chunks = evidence.get("chunks") or []
|
|
if not chunks:
|
|
return {
|
|
"answer": (
|
|
f"{BANNER} No controlled procedure was found in the active "
|
|
"document set for this question."
|
|
),
|
|
"procedure": None,
|
|
"prerequisites_verbatim": [],
|
|
}
|
|
|
|
top = chunks[0]
|
|
procedure = {
|
|
"doc_number": top.get("doc_number") or top.get("source_file"),
|
|
"title": top.get("section_title") or top.get("source_file"),
|
|
"revision": top.get("revision") or "unknown",
|
|
"effective_date": top.get("effective_date"),
|
|
"authorising_role": None,
|
|
"controlled_copy_location": (
|
|
"Controlled copy location is not held in the document index - "
|
|
"obtain the controlled copy through document control."
|
|
),
|
|
}
|
|
answer = (
|
|
f"{BANNER} A candidate controlled procedure was located and is "
|
|
"identified below. Prerequisites were not extracted: quoting them "
|
|
"verbatim is an extraction step this mode does not perform, and "
|
|
"reconstructing them from the retrieved text is precisely what this "
|
|
"class forbids. Work from the controlled copy."
|
|
)
|
|
return {"answer": answer, "procedure": procedure, "prerequisites_verbatim": []}
|
|
|
|
|
|
def _advisory(question: str, evidence: dict[str, Any]) -> dict[str, Any]:
|
|
"""Evidence rows come from Cube. The deferral is the whole point.
|
|
|
|
Note what is NOT here: no number is selected, ranked or described as
|
|
suitable. The stub could not do that even if asked - it has no way to form
|
|
an opinion, which makes this the one class where a stub behaves almost
|
|
exactly as the real thing should.
|
|
"""
|
|
rows = evidence.get("rows") or []
|
|
window = evidence.get("time_window", {}).get("description", "the window queried")
|
|
|
|
def _interesting(row: dict[str, Any]) -> list[str]:
|
|
# Ordering only. It decides which column is shown, never what it says -
|
|
# the value is whatever Cube returned.
|
|
keys = [
|
|
k for k, v in row.items()
|
|
if isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
]
|
|
preferred = [
|
|
k for k in keys
|
|
if any(w in k.lower() for w in ("rate", "discharge", "level", "count"))
|
|
]
|
|
return preferred + [k for k in keys if k not in preferred]
|
|
|
|
items: list[dict[str, Any]] = []
|
|
for row in rows[:6]:
|
|
for key in _interesting(row):
|
|
value = row[key]
|
|
items.append(
|
|
{
|
|
"description": f"{key} over {window}",
|
|
"metric": key,
|
|
"value": float(value),
|
|
"unit": "",
|
|
"sample_size": len(rows),
|
|
}
|
|
)
|
|
break
|
|
|
|
if items:
|
|
answer = (
|
|
f"{BANNER} What the history shows over {window} is set out in the "
|
|
"evidence below. No operating parameter has been selected from it."
|
|
)
|
|
else:
|
|
answer = (
|
|
f"{BANNER} There are no records of station operations for {window}."
|
|
)
|
|
return {
|
|
"answer": answer,
|
|
"evidence": items,
|
|
"documented_limits": [],
|
|
"deferral": (
|
|
"This system does not recommend setpoints or operating parameters. "
|
|
"That decision needs a competent person with sight of current "
|
|
"equipment condition and concurrent operations. In this mode it "
|
|
"could not make a recommendation even if it were permitted to - no "
|
|
"model was called."
|
|
),
|
|
}
|
|
|
|
|
|
BUILDERS = {
|
|
QuestionClass.HISTORICAL: _historical,
|
|
QuestionClass.REFERENCE: _reference,
|
|
QuestionClass.PROCEDURAL: _procedural,
|
|
QuestionClass.ADVISORY: _advisory,
|
|
}
|
|
|
|
|
|
def generated_fields(
|
|
klass: QuestionClass, question: str, evidence: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
"""Stand in for the model's half of the payload. Deterministic."""
|
|
builder = BUILDERS.get(klass)
|
|
if builder is None:
|
|
raise ValueError(f"no stub builder for {klass}")
|
|
return builder(question, evidence)
|