The UI calls the API with credentials: "include", because ai.yokogawa.tech and api.yokogawa.tech are different origins and the Authelia session cookie has to be attached explicitly. The CORS middleware never set allow_credentials, and a browser refuses a credentialed cross-origin request unless the response says Access-Control-Allow-Credentials: true. It fails at the preflight, so the real request is never sent: Access to fetch at '.../ask' has been blocked by CORS policy: the value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'. Every question from the UI would have failed at Phase 7 with "Could not reach the assistant" - the app's network-error branch, which says nothing about CORS and points at the wrong layer entirely. The API is fine; curl against it passes, because curl is not a browser and does not enforce this. Found driving the built UI in a browser. It is not reachable by any test that does not involve a browser, which is the useful part: the Phase 7 gate says an operator reaches the UI and gets an answer end to end, and that gate is the first thing that would have caught it - at the point where DNS, Caddy and Authelia are all new too, and any of them a plausible suspect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
172 lines
5.7 KiB
Python
172 lines
5.7 KiB
Python
"""FastAPI entrypoint for ai-api.
|
|
|
|
Reached at https://api.yokogawa.tech, behind Caddy and Authelia. There is no
|
|
authentication in this application because Authelia does it at the edge - which
|
|
also means this app must never be given a published host port, and never a
|
|
Caddyfile block without `import authelia`.
|
|
|
|
Every request is traced to Langfuse with its class, confidence, tool calls,
|
|
retrieved chunks, tokens, latency and contract result. Tracing failures never
|
|
break the request path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
import uuid
|
|
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from pydantic import BaseModel, Field
|
|
|
|
import agent
|
|
from config import settings
|
|
from contracts import ContractViolation
|
|
from guardrails import GuardrailViolation
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
)
|
|
log = logging.getLogger("api")
|
|
|
|
app = FastAPI(
|
|
title="WRPS Plant Operations Assistant",
|
|
version="0.1.0",
|
|
description=(
|
|
"Information retrieval and analysis for the Waterloo Road Pump Station. "
|
|
"Not a control system, not an advisory controller, not a substitute for "
|
|
"a competent person."
|
|
),
|
|
)
|
|
|
|
# The browser reaches the API by its own public hostname, so a same-site
|
|
# origin is all that is ever needed.
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["https://ai.yokogawa.tech"],
|
|
# The UI sends credentials: "include" so the browser attaches the Authelia
|
|
# session cookie. Without this the preflight fails and NO cross-origin call
|
|
# succeeds - the browser refuses a credentialed request unless the response
|
|
# says Access-Control-Allow-Credentials: true.
|
|
allow_credentials=True,
|
|
allow_methods=["GET", "POST"],
|
|
allow_headers=["Content-Type"],
|
|
)
|
|
|
|
|
|
def _langfuse():
|
|
"""Langfuse client, or None. Never let observability break the answer path."""
|
|
cfg = settings()
|
|
if not (cfg.langfuse_public_key and cfg.langfuse_secret_key):
|
|
return None
|
|
try:
|
|
from langfuse import Langfuse
|
|
|
|
return Langfuse(
|
|
public_key=cfg.langfuse_public_key,
|
|
secret_key=cfg.langfuse_secret_key,
|
|
host=cfg.langfuse_host,
|
|
)
|
|
except Exception:
|
|
log.exception("Langfuse unavailable - continuing untraced")
|
|
return None
|
|
|
|
|
|
class AskRequest(BaseModel):
|
|
question: str = Field(min_length=3, max_length=1000)
|
|
|
|
|
|
class AskResponse(BaseModel):
|
|
request_id: str
|
|
question_class: str
|
|
confidence: float
|
|
downgraded_reason: str | None = None
|
|
answer: dict
|
|
latency_ms: int
|
|
|
|
|
|
@app.get("/healthz")
|
|
def healthz() -> dict:
|
|
"""Liveness plus configuration shape. No secrets, ever - values are
|
|
reported as set/unset."""
|
|
return {"status": "ok", "config": settings().redacted()}
|
|
|
|
|
|
@app.post("/ask", response_model=AskResponse)
|
|
def ask(request: AskRequest) -> AskResponse:
|
|
request_id = str(uuid.uuid4())
|
|
started = time.perf_counter()
|
|
client = _langfuse()
|
|
trace = None
|
|
if client is not None:
|
|
try:
|
|
trace = client.trace(
|
|
id=request_id, name="ask", input={"question": request.question}
|
|
)
|
|
except Exception:
|
|
log.exception("could not open a Langfuse trace")
|
|
|
|
try:
|
|
answer, classification = agent.answer(request.question, trace=trace)
|
|
except ContractViolation as violation:
|
|
# The offending output has already gone to Langfuse with the whole
|
|
# generated text. What comes back here says nothing about it: an error
|
|
# message is not a side channel for content that failed a safety check.
|
|
log.warning("contract failure request_id=%s rule=%s", request_id, violation.rule)
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail={
|
|
"request_id": request_id,
|
|
"error": "contract_not_met",
|
|
"message": (
|
|
"I could not produce an answer that meets the safety contract "
|
|
"for this question. The attempt has been logged for review."
|
|
),
|
|
},
|
|
) from violation
|
|
except GuardrailViolation as violation:
|
|
log.warning("guardrail refusal request_id=%s rule=%s", request_id, violation.rule)
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail={
|
|
"request_id": request_id,
|
|
"error": "query_refused",
|
|
"message": f"The query was refused: {violation.rule}.",
|
|
},
|
|
) from violation
|
|
except Exception as exc:
|
|
log.exception("unhandled error request_id=%s", request_id)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={"request_id": request_id, "error": "internal_error"},
|
|
) from exc
|
|
|
|
latency_ms = int((time.perf_counter() - started) * 1000)
|
|
payload = answer.model_dump(mode="json")
|
|
|
|
if trace is not None:
|
|
try:
|
|
trace.update(
|
|
output=payload,
|
|
metadata={
|
|
"question_class": classification.question_class.value,
|
|
"confidence": classification.confidence,
|
|
"downgraded_reason": classification.downgraded_reason,
|
|
"contract_result": "passed",
|
|
"used_fixture_data": payload.get("used_fixture_data", False),
|
|
"latency_ms": latency_ms,
|
|
},
|
|
)
|
|
except Exception:
|
|
log.exception("could not finalise the Langfuse trace")
|
|
|
|
return AskResponse(
|
|
request_id=request_id,
|
|
question_class=classification.question_class.value,
|
|
confidence=classification.confidence,
|
|
downgraded_reason=classification.downgraded_reason,
|
|
answer=payload,
|
|
latency_ms=latency_ms,
|
|
)
|