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>
271 lines
9.6 KiB
TypeScript
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>
|
|
);
|
|
}
|