yau-plant-assistant/web/src/App.tsx
Claude 76af3156fa Serve the operator page and /ask from one origin
api.yokogawa.tech has a public A record but no pinpoint record on the DC, so
it does not resolve from inside the VNet at all. The browser called it by
hostname, which means an operator on cicore1 would have loaded the page and
had every question fail on DNS - the exact gap Phase 7's gate exists to catch,
and one an engineer's laptop cannot see.

Caddy now routes /ask under ai.yokogawa.tech to ai-api, inside a route block
so import authelia still runs first: forward_auth sorts after handle in the
default directive order, and outside a route the handles would be terminal and
the gate would never run. Only /ask is routed - the Phase 9 publisher rule is
scoped to api.yokogawa.tech and a wider route here would leave it inert.

Also fixes the fallback it replaces. The build arg defaults to "", and
`?? "https://api.yokogawa.tech"` does not catch an empty string, so the
documented real-deployment build resolved the API base to "" and posted /ask
at ai-web, which 404s it. verify.sh and deploy.sh now check both the route and
whether the built bundle carries the hostname.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:48:22 +10:00

271 lines
9.6 KiB
TypeScript

import { useState } from "react";
import type { AnswerBody, AskResponse, Citation } from "./types";
// Same-origin, always, unless a build explicitly overrides it. Caddy routes
// /ask under ai.yokogawa.tech to ai-api (caddy/ai-routes.caddy), so the page
// and the API share an origin and the browser attaches the Authelia session
// cookie with no cross-origin handling at all.
//
// It is same-origin because an operator on cicore1 CANNOT resolve
// api.yokogawa.tech: LAN hosts resolve through the DC, which holds a pinpoint
// record for ai.yokogawa.tech and none for api. A cross-origin build loads and
// then fails every question on DNS.
//
// VITE_API_BASE remains for a tunnelled build, which points it at
// http://localhost:8001 - that has NO AUTHENTICATION in front of it, is
// reachable only through an SSH tunnel from one machine, and is not a
// deployment. The empty-string check is deliberate: the build arg defaults to
// "" (web/Dockerfile), and `??` does not catch an empty string - it read as a
// valid base and sent /ask to the origin with no route for it.
const API = import.meta.env.DEV
? "/api"
: (import.meta.env.VITE_API_BASE || "");
export default function App() {
const [question, setQuestion] = useState("");
const [result, setResult] = useState<AskResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [showWorking, setShowWorking] = useState(true);
// Stub mode only - the API ignores it otherwise. See api/stub.py.
const [forcedClass, setForcedClass] = useState("");
async function ask(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError(null);
setResult(null);
try {
const response = await fetch(`${API}/ask`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(
forcedClass ? { question, question_class: forcedClass } : { question },
),
});
const body = await response.json();
if (!response.ok) {
setError(body?.detail?.message ?? "The request failed.");
return;
}
setResult(body as AskResponse);
} catch {
setError("Could not reach the assistant.");
} finally {
setBusy(false);
}
}
return (
<main>
<header>
<h1>Waterloo Road Pump Station Plant Assistant</h1>
<p className="subtitle">
Answers grounded in plant history and controlled documents. Not a
control system, and not a substitute for a competent person.
</p>
</header>
<form onSubmit={ask}>
<textarea
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="e.g. How many times did the wet well high level alarm come up last week?"
rows={3}
/>
<div className="row">
<button type="submit" disabled={busy || question.trim().length < 3}>
{busy ? "Working…" : "Ask"}
</button>
<label>
<input
type="checkbox"
checked={showWorking}
onChange={(e) => setShowWorking(e.target.checked)}
/>
Show working
</label>
{/* Only has an effect while the API runs with NO_LLM_STUB on. There
is no classifier without a model, so the class is chosen here
rather than guessed - see api/stub.py for why guessing it with
keywords would be worse than asking. */}
<label className="forced-class">
Class (stub mode)
<select
value={forcedClass}
onChange={(e) => setForcedClass(e.target.value)}
>
<option value="">Classify automatically</option>
<option value="historical">Historical</option>
<option value="reference">Reference</option>
<option value="procedural">Procedural</option>
<option value="advisory">Advisory</option>
<option value="unclear">Unclear</option>
</select>
</label>
</div>
</form>
{error && <div className="error">{error}</div>}
{result && <Answer result={result} showWorking={showWorking} />}
</main>
);
}
function Answer({ result, showWorking }: { result: AskResponse; showWorking: boolean }) {
const a = result.answer;
return (
<section className="answer">
<div className="class-chip">{result.question_class}</div>
{/* An answer nobody generated must not look like one that was. Same
place, same weight and the same reasoning as the fixture banner. */}
{a.stub_mode && (
<div className="banner stub">
<strong>No-LLM stub mode.</strong> No language model was called. The
class was chosen by hand, retrieval was lexical rather than semantic,
and the prose is a fixed placeholder. Figures, citations and document
identities are real query results; the wording around them means
nothing.
</div>
)}
{/* Fixture data must never reach a slide unlabelled. */}
{a.used_fixture_data && (
<div className="banner fixture">
<strong>Fixture data.</strong> These figures come from generated test
data standing in for the plant historian. They are not real plant
history and must not be quoted as such.
</div>
)}
{/* Procedural and Advisory carry a visible scope banner. An operator must
not have to infer the limits of the answer from its tone. */}
{a.scope_banner && <div className="banner scope">{a.scope_banner}</div>}
<p className="prose">{a.answer}</p>
{a.procedure && <Procedure procedure={a.procedure} />}
{a.prerequisites_verbatim && a.prerequisites_verbatim.length > 0 && (
<div className="block">
<h3>Prerequisites, quoted from the controlled document</h3>
<ul>
{a.prerequisites_verbatim.map((p, i) => (
<li key={i}><q>{p}</q></li>
))}
</ul>
</div>
)}
{a.evidence && a.evidence.length > 0 && <EvidenceTable body={a} />}
{a.deferral && <p className="deferral">{a.deferral}</p>}
{a.clarifying_question && <p className="prose">{a.clarifying_question}</p>}
{a.citations && a.citations.length > 0 && <Citations citations={a.citations} />}
{showWorking && <Working result={result} />}
</section>
);
}
function Procedure({ procedure }: { procedure: NonNullable<AnswerBody["procedure"]> }) {
return (
<div className="block">
<h3>Controlled procedure</h3>
<dl>
<dt>Document</dt><dd>{procedure.doc_number} {procedure.title}</dd>
<dt>Revision</dt><dd>{procedure.revision}</dd>
<dt>Effective</dt><dd>{procedure.effective_date ?? "not stated"}</dd>
<dt>Authorised by</dt><dd>{procedure.authorising_role ?? "not stated"}</dd>
<dt>Controlled copy</dt><dd>{procedure.controlled_copy_location}</dd>
</dl>
</div>
);
}
function EvidenceTable({ body }: { body: AnswerBody }) {
return (
<div className="block">
<h3>Evidence</h3>
<table>
<thead>
<tr><th>Observation</th><th>Value</th><th>Sample size</th></tr>
</thead>
<tbody>
{body.evidence!.map((e, i) => (
<tr key={i}>
<td>{e.description}</td>
<td>{e.value ?? "—"} {e.unit ?? ""}</td>
{/* A rate without its denominator invites a small sample to be
read as a trend. */}
<td>{e.sample_size ?? "—"}</td>
</tr>
))}
</tbody>
</table>
{body.documented_limits && body.documented_limits.length > 0 && (
<>
<h3>Documented limits</h3>
<ul>
{body.documented_limits.map((l, i) => (
<li key={i}>
{l.description}: <strong>{l.value} {l.unit ?? ""}</strong>{" "}
<span className="cite">
({l.citation.doc_number} rev {l.citation.revision})
</span>
</li>
))}
</ul>
</>
)}
</div>
);
}
function Citations({ citations }: { citations: Citation[] }) {
return (
<div className="block">
<h3>Citations</h3>
<ul>
{citations.map((c, i) => (
<li key={i}>
<strong>{c.doc_number}</strong> {c.title}
{" · "}rev {c.revision}
{" · "}effective {c.effective_date ?? "not stated"}
{c.page !== null && <> · p.{c.page}</>}
</li>
))}
</ul>
</div>
);
}
function Working({ result }: { result: AskResponse }) {
const a = result.answer;
return (
<details className="working" open>
<summary>Working</summary>
<dl>
<dt>Class</dt><dd>{result.question_class} (confidence {result.confidence.toFixed(2)})</dd>
{result.downgraded_reason && (<><dt>Routing</dt><dd>{result.downgraded_reason}</dd></>)}
{a.time_window && (<><dt>Window</dt><dd>{a.time_window.description}</dd></>)}
{a.row_count !== undefined && (<><dt>Rows</dt><dd>{a.row_count}</dd></>)}
<dt>Latency</dt><dd>{result.latency_ms} ms</dd>
<dt>Request</dt><dd><code>{result.request_id}</code></dd>
</dl>
{a.query && (
<>
<h4>Query</h4>
<pre>{JSON.stringify(a.query, null, 2)}</pre>
</>
)}
{a.rows && a.rows.length > 0 && (
<>
<h4>Rows</h4>
<pre>{JSON.stringify(a.rows.slice(0, 25), null, 2)}</pre>
</>
)}
</details>
);
}