diff --git a/apps/web/README.md b/apps/web/README.md index 199daea..7811b5a 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -28,8 +28,12 @@ http://127.0.0.1:3000 ## API Base URL -The frontend reads the local FastAPI backend URL from -`NEXT_PUBLIC_API_BASE_URL`. +The Workbench uses two API targets in Docker-aware runtimes: + +- `NEXT_PUBLIC_API_BASE_URL` is the browser-visible FastAPI URL shown in the UI + and used by client-side requests. +- `FIP_API_URL` is the server-side container-to-container API URL used by + server-rendered Next.js requests. Default: @@ -43,6 +47,14 @@ Override without code changes: NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8001 npm run dev ``` +When running through Docker Compose, keep the public URL pointed at the host +published API port and use the Compose service name for server-side requests: + +```text +NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 +FIP_API_URL=http://api:8000 +``` + ## Runtime Direction Use `../../docs/runtime/DOCKER_COMPOSE.md` for the detailed local runtime guide @@ -86,6 +98,12 @@ RCA/CAPA draft panels should be treated as missing runtime data until Demo-Factory, FIP Compose, connector ingestion, and Process Sentinel have all run. +The overview dashboard includes a deterministic context question form that +posts to `POST /context/questions`. It answers only from current browser-safe +API data such as domain context, Sentinel detections, evidence, +recommendations, runtime health, and connection profile summaries. It does not +call an LLM, AI SDK, model gateway, RAG index, or external provider. + For focused Workbench development outside the full Compose stack, run the API directly from the repository root: @@ -96,6 +114,7 @@ make api ## Routes - `/` - overview dashboard +- `/process-sentinel` - Process Sentinel workflow entry point - `/connections` - OPC-UA, MQTT, and BACnet profile management with redacted credential references - `/protocol-diagnostics` - read-only connection health and mapping diagnostics diff --git a/apps/web/app/components/demo-state.tsx b/apps/web/app/components/demo-state.tsx index e97bfbc..411a148 100644 --- a/apps/web/app/components/demo-state.tsx +++ b/apps/web/app/components/demo-state.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import type { HealthResponse } from "../../lib/api-client"; import { getApiBaseUrl } from "../../lib/api-client"; +import { productCopy } from "../../lib/product-copy"; type ApiErrorPanelProps = { apiBaseUrl?: string; @@ -46,30 +47,31 @@ export function ApiConnectionBanner({ health, }: ApiConnectionBannerProps) { return ( -
+
- Local API connected + Integration details - Workbench data is coming from the configured FIP API target for the - external-source runtime. + Operational views are reading from the configured manufacturing data + service. Technical endpoint details are shown here for integration + support.
-
API target
+
{productCopy.integrationEndpointLabel}
{apiBaseUrl}
-
Health
+
Data health
{health?.status ?? "Not checked"}
-
Source
-
{health?.source_mode ?? "External source expected"}
+
Data source
+
{productCopy.dataSourceLabel}
-
Connector mode
-
{health?.connector_mode ?? "Read-only runtime expected"}
+
Writeback policy
+
{productCopy.writebackPolicyDetail}
@@ -84,13 +86,14 @@ export function ApiErrorPanel({
API connection issue - The Workbench could not reach the local FIP API at {apiBaseUrl}. + The Workbench could not reach the configured manufacturing data service. - Start Demo-Factory, then start the FIP Docker Compose stack. Check{" "} - curl http://localhost:8000/health, then refresh this page. - If the API is using a different port, restart the Workbench with{" "} - NEXT_PUBLIC_API_BASE_URL set to that target. + Verify the validation data source, service health, and integration + endpoint configuration, then refresh this page. + + + Integration endpoint: {apiBaseUrl} Details: {message}
@@ -117,12 +120,12 @@ export function MissingDataPanel({ nextStep, text, title }: MissingDataPanelProp ); } -export function LoadingState({ title = "Loading local demo data" }: LoadingStateProps) { +export function LoadingState({ title = "Loading operational data" }: LoadingStateProps) { return (
{title} - Connecting to the local FIP API runtime. + Connecting to the configured manufacturing data service.
); diff --git a/apps/web/app/components/operator-navigation.tsx b/apps/web/app/components/operator-navigation.tsx new file mode 100644 index 0000000..40e946c --- /dev/null +++ b/apps/web/app/components/operator-navigation.tsx @@ -0,0 +1,75 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +type NavItem = { + href: string; + icon: string; + label: string; +}; + +type NavGroup = { + items: NavItem[]; + label: string; +}; + +const navGroups: NavGroup[] = [ + { + items: [{ href: "/", icon: "O", label: "Overview" }], + label: "Platform", + }, + { + items: [ + { href: "/process-sentinel", icon: "P", label: "Process Sentinel" }, + { href: "/detections", icon: "D", label: "Detections" }, + { href: "/recommendations", icon: "R", label: "Recommendations" }, + { href: "/rca-capa-draft", icon: "C", label: "RCA/CAPA" }, + ], + label: "Sentinel workflows", + }, + { + items: [ + { href: "/connections", icon: "N", label: "Connections" }, + { href: "/protocol-diagnostics", icon: "H", label: "Protocol Diagnostics" }, + { href: "/tag-source-browser", icon: "T", label: "Tag/Source Browser" }, + ], + label: "Protocol operations", + }, +]; + +export function OperatorNavigation() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/apps/web/app/context-question-panel.tsx b/apps/web/app/context-question-panel.tsx new file mode 100644 index 0000000..a7fe5ad --- /dev/null +++ b/apps/web/app/context-question-panel.tsx @@ -0,0 +1,118 @@ +"use client"; + +import Link from "next/link"; +import { FormEvent, useId, useState } from "react"; + +import { + type ContextQuestionResponse, + formatApiError, + workbenchApi, +} from "../lib/api-client"; + +const exampleQuestions = [ + "What is the most important finding right now?", + "Why was the primary detection flagged?", + "How many recommendations are pending review?", +]; +const confidenceLabels: Record = { + partial: "partial", + supported: "supported", + unsupported: "unsupported", +}; + +export function ContextQuestionPanel() { + const questionId = useId(); + const [question, setQuestion] = useState(""); + const [answer, setAnswer] = useState(null); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + const trimmedQuestion = question.trim(); + if (!trimmedQuestion || isSubmitting) { + return; + } + setIsSubmitting(true); + setError(null); + try { + setAnswer(await workbenchApi.askContextQuestion({ question: trimmedQuestion })); + } catch (submitError) { + setAnswer(null); + setError(formatApiError(submitError)); + } finally { + setIsSubmitting(false); + } + } + + const suggestions = answer?.suggested_questions.length + ? answer.suggested_questions + : exampleQuestions; + + return ( +
+

+ Ask about current factory context +

+
+ +
+ setQuestion(event.target.value)} + placeholder="Ask about detections, evidence, recommendations, source health, or current batch..." + type="text" + value={question} + /> + +
+
+ +
+ {error ? ( +

Question failed: {error}

+ ) : null} + {answer ? ( +
+
+ + {confidenceLabels[answer.confidence]} + + {answer.question} +
+

{answer.answer}

+ {answer.sources.length > 0 ? ( +
    + {answer.sources.map((source) => ( +
  • + + {source.label} + {source.type} + +
  • + ))} +
+ ) : null} + {answer.confidence === "unsupported" ? ( +
+ Try asking +
    + {suggestions.map((suggestion) => ( +
  • {suggestion}
  • + ))} +
+
+ ) : null} +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/app/detections/[detectionId]/page.tsx b/apps/web/app/detections/[detectionId]/page.tsx index 2336314..3a8d732 100644 --- a/apps/web/app/detections/[detectionId]/page.tsx +++ b/apps/web/app/detections/[detectionId]/page.tsx @@ -10,7 +10,6 @@ import { type Detection, type EvidenceItem, formatApiError, - getApiBaseUrl, workbenchApi, } from "../../../lib/api-client"; @@ -34,15 +33,16 @@ export default async function DetectionDetailPage({ params }: DetectionDetailPag

Detection detail

- Read-only Process Sentinel detection context from the local FIP API. + Review the selected Process Sentinel case, linked evidence, and + recommended review path.

{!result.ok && result.notFound ? ( - The local FIP API did not return a detection for{" "} + The manufacturing data service did not return a detection for{" "} {detectionId}. Open the detection list and choose a current runtime detection. @@ -55,7 +55,7 @@ export default async function DetectionDetailPage({ params }: DetectionDetailPag
- Detection summary + Case summary

{result.detection.summary}

@@ -131,7 +131,7 @@ export default async function DetectionDetailPage({ params }: DetectionDetailPag

RCA/CAPA draft

Preview the human-reviewed RCA/CAPA draft language generated - from the selected external-source detection. + from the selected Process Sentinel case.

) : null} -
-

- Configured API target: {getApiBaseUrl()} -

-
); } @@ -190,11 +185,11 @@ function EvidenceTimeline({ evidenceItems }: { evidenceItems: EvidenceItem[] }) aria-labelledby="evidence-timeline-heading" >
- External-source evidence + Linked evidence

Evidence timeline

- Chronological Process Sentinel evidence from the local FIP API. Use - this to understand why the finding exists before reviewing any + Chronological Process Sentinel evidence for the selected case. Use + this to understand why the finding exists before reviewing the recommendation.

@@ -205,11 +200,11 @@ function EvidenceTimeline({ evidenceItems }: { evidenceItems: EvidenceItem[] }) {evidenceItems.length === 0 ? ( - This detection exists, but the local FIP API did not return - evidence items from the current external-source event store yet. + This detection exists, but no linked evidence items were returned + from the current operational state yet. } title="No evidence available" @@ -278,8 +273,8 @@ function EvidenceTimeline({ evidenceItems }: { evidenceItems: EvidenceItem[] }) function buildFlagExplanation(detection: Detection): string { const typeContext = detection.detection_type === "quality_drift" - ? "Process Sentinel saw a quality trend move away from the expected demo baseline" - : "Process Sentinel saw a process signal move outside the expected demo operating range"; + ? "Process Sentinel identified a quality trend moving away from the expected operating band" + : "Process Sentinel identified a process signal moving outside the expected operating range"; const assetContext = detection.related_asset_ids.length > 0 ? ` for ${formatAssets(detection.related_asset_ids)}` @@ -294,12 +289,12 @@ function buildFlagExplanation(detection: Detection): string { detection.confidence * 100, )}% confidence over ${formatTimeWindow( detection, - )}. This is advisory external-source context for human review, not an autonomous action.`; + )}. This is advisory context for human review, not an autonomous action.`; } function buildTimelineMeaning(evidenceItems: EvidenceItem[]): string { if (evidenceItems.length === 0) { - return "The detection is present, but there is no supporting timeline evidence available yet in the current external-source event store."; + return "The detection is present, but there is no supporting timeline evidence available yet in the current operational state."; } const evidenceTypes = [ diff --git a/apps/web/app/detections/page.tsx b/apps/web/app/detections/page.tsx index 6306684..37ecede 100644 --- a/apps/web/app/detections/page.tsx +++ b/apps/web/app/detections/page.tsx @@ -4,7 +4,6 @@ import { ApiErrorPanel, EmptyState, StatusBadge } from "../components/demo-state import { type Detection, formatApiError, - getApiBaseUrl, workbenchApi, } from "../../lib/api-client"; @@ -18,16 +17,15 @@ export default async function DetectionsPage() {

Detections

- Process Sentinel detections from the current external-source event - store. Open a detection to inspect the current summary and routing - context. + Process Sentinel cases generated from monitored process signals and + linked evidence.

{!result.ok ? : null} {result.ok && result.detections.length === 0 ? ( ) : null} @@ -75,27 +73,35 @@ export default async function DetectionsPage() {
Related assets
{formatAssets(detection.related_asset_ids)}
+
+
Evidence count
+
{result.evidenceCounts[detection.detection_id] ?? 0}
+
- Open detection + Review case ))} ) : null} -
-

- Configured API target: {getApiBaseUrl()} -

-
); } async function loadDetections() { try { - return { detections: await workbenchApi.listDetections(), ok: true as const }; + const detections = await workbenchApi.listDetections(); + const evidenceCounts = Object.fromEntries( + await Promise.all( + detections.map(async (detection) => [ + detection.detection_id, + (await workbenchApi.listDetectionEvidence(detection.detection_id)).length, + ]), + ), + ); + return { detections, evidenceCounts, ok: true as const }; } catch (error) { return { message: formatApiError(error), ok: false as const }; } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 0ef5116..a24efe7 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,5 +1,5 @@ :root { - --background: #e8eef3; + --background: #eef2f6; --surface: #ffffff; --surface-muted: #f5f8fa; --surface-subtle: #f0f5f8; @@ -11,8 +11,8 @@ --accent: #0f766e; --accent-strong: #0b5d57; --accent-bg: #d7f4eb; - --nav: #101820; - --nav-2: #1d2b36; + --nav: #07111b; + --nav-2: #101f2b; --nav-text: #d8e2ea; --nav-muted: #91a4b3; --warning: #b45309; @@ -52,7 +52,7 @@ a { .operator-shell { display: grid; - grid-template-columns: 276px minmax(0, 1fr); + grid-template-columns: 248px minmax(0, 1fr); min-height: 100vh; } @@ -81,11 +81,11 @@ a { display: flex; height: 100vh; flex-direction: column; - gap: 24px; + gap: 22px; border-right: 1px solid #243541; background: var(--nav); color: var(--nav-text); - padding: 22px 18px; + padding: 18px 14px; } .sidebar-branding { @@ -94,8 +94,9 @@ a { } .brand { - display: grid; - gap: 2px; + display: flex; + align-items: center; + gap: 12px; border-radius: 7px; } @@ -104,6 +105,21 @@ a { outline-offset: 4px; } +.brand-mark { + display: inline-flex; + width: 34px; + height: 34px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid #315064; + border-radius: 8px; + background: #0b2634; + color: #dff7ff; + font-size: 0.66rem; + font-weight: 860; +} + .brand-name { color: #ffffff; font-size: 1rem; @@ -111,15 +127,9 @@ a { letter-spacing: 0; } -.brand-context { - color: var(--nav-muted); - font-size: 0.82rem; - font-weight: 560; -} - .primary-nav { display: grid; - gap: 22px; + gap: 14px; } .nav-group { @@ -128,7 +138,7 @@ a { } .nav-group h2 { - margin: 0; + margin: 0 8px; color: var(--nav-muted); font-size: 0.72rem; font-weight: 780; @@ -141,10 +151,10 @@ a { } .nav-link { - display: flex; + display: grid; + grid-template-columns: 30px minmax(0, 1fr); min-height: 42px; align-items: center; - justify-content: space-between; gap: 10px; border: 1px solid transparent; border-radius: 7px; @@ -156,73 +166,129 @@ a { } .nav-link:hover, -.nav-link:focus-visible { +.nav-link:focus-visible, +.nav-link-active { border-color: #2d4352; background: var(--nav-2); color: #ffffff; - outline: 3px solid var(--focus-ring); - outline-offset: 2px; } -.nav-link-disabled { - color: var(--nav-muted); - cursor: default; +.nav-link:focus-visible { + outline: 3px solid var(--focus-ring); + outline-offset: 2px; } -.nav-link-disabled:hover { - background: transparent; - color: var(--nav-muted); +.nav-link-active { + border-color: #0b8b94; + background: linear-gradient(90deg, #0f7f86, #134050); + box-shadow: inset 3px 0 0 #17b7bc; } -.nav-link-meta { - border: 1px solid #314756; +.nav-icon { + display: inline-flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 1px solid #2a4556; border-radius: 999px; - background: var(--nav-2); - color: var(--nav-text); - font-size: 0.68rem; - font-weight: 760; - line-height: 1; - padding: 5px 7px; - text-transform: uppercase; + color: #d8e2ea; + font-size: 0.72rem; + font-weight: 820; } .operator-workspace { + display: grid; + min-height: 100vh; min-width: 0; + grid-template-rows: auto minmax(0, 1fr) auto; } -.status-strip { - display: grid; - gap: 8px; - margin-top: auto; - border-top: 1px solid #243541; - padding-top: 16px; +.operator-topbar { + display: flex; + min-height: 58px; + align-items: center; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid #d6dee7; + background: #ffffff; + padding: 0 24px; } -.status-strip div { - display: grid; +.topbar-actions { + display: flex; min-width: 0; - gap: 4px; - border: 1px solid #243541; + align-items: center; + gap: 10px; +} + +.topbar-config-link, +.topbar-site-button, +.topbar-icon-button, +.topbar-avatar { + display: inline-flex; + min-height: 36px; + align-items: center; + justify-content: center; + border: 1px solid var(--border); border-radius: 7px; - background: var(--nav-2); - padding: 10px 11px; + background: #ffffff; + color: var(--text); + font: inherit; + font-size: 0.88rem; + font-weight: 720; + padding: 8px 11px; } -.status-strip .status-label { - color: var(--nav-muted); +.topbar-config-link { + border-color: #0f766e; + color: #0b5d57; } -.status-strip strong { +.topbar-icon-button, +.topbar-avatar { + width: 36px; + padding: 0; +} + +.topbar-avatar { + border-radius: 999px; + background: #0f1e2b; color: #ffffff; - overflow-wrap: anywhere; - font-size: 0.9rem; - line-height: 1.25; + font-size: 0.8rem; +} + +.topbar-config-link:hover, +.topbar-config-link:focus-visible, +.topbar-icon-button:hover, +.topbar-icon-button:focus-visible { + border-color: var(--accent); + outline: 3px solid var(--focus-ring); + outline-offset: 2px; +} + +.topbar-icon-button:disabled { + cursor: default; + opacity: 0.7; } .page-shell { - width: min(1180px, calc(100% - 32px)); + width: min(1420px, calc(100% - 48px)); margin: 0 auto; - padding: 28px 0 48px; + padding: 22px 0 24px; +} + +.operator-footer { + display: flex; + min-height: 50px; + align-items: center; + justify-content: flex-end; + gap: 28px; + border-top: 1px solid #d6dee7; + background: #07111b; + color: #c3d0dc; + font-size: 0.82rem; + padding: 0 24px; } .page-shell:focus-visible { @@ -270,6 +336,173 @@ a { margin-top: 24px; } +.context-question-panel { + display: grid; + gap: 10px; + margin: 0 0 18px; +} + +.context-question-panel h2 { + margin-bottom: 0; + color: var(--muted); + font-size: 0.78rem; + font-weight: 760; + text-transform: uppercase; +} + +.context-question-form { + display: grid; + gap: 7px; +} + +.context-question-form label { + color: var(--muted); + font-size: 0.78rem; + font-weight: 760; + text-transform: uppercase; +} + +.context-question-form div { + display: flex; + gap: 8px; +} + +.context-question-form input { + width: 100%; + min-width: 0; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--surface); + color: var(--text); + font: inherit; + line-height: 1.5; + padding: 12px 13px; +} + +.context-question-form input:focus { + border-color: var(--accent); + outline: 3px solid var(--focus-ring); + outline-offset: 1px; +} + +.context-question-form button { + min-width: 92px; + border: 1px solid var(--accent); + border-radius: 7px; + background: var(--accent); + color: #ffffff; + cursor: pointer; + font: inherit; + font-weight: 760; + line-height: 1; + padding: 12px 14px; +} + +.context-question-form button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.context-question-form button:hover:not(:disabled), +.context-question-form button:focus-visible { + background: var(--accent-strong); + outline: 3px solid var(--focus-ring); + outline-offset: 2px; +} + +.context-question-result { + min-height: 1.25em; +} + +.context-question-result article { + display: grid; + gap: 10px; + border-left: 4px solid var(--accent); + background: #e7f3f1; + color: #21423e; + padding: 14px; +} + +.context-question-result p { + margin-bottom: 0; + line-height: 1.5; +} + +.context-question-result-heading { + display: grid; + gap: 6px; +} + +.context-confidence { + width: fit-content; + border: 1px solid #9bd3ca; + border-radius: 999px; + background: var(--accent-bg); + color: var(--accent-strong); + font-size: 0.72rem; + font-weight: 800; + line-height: 1; + padding: 6px 8px; + text-transform: uppercase; +} + +.context-confidence-partial { + border-color: #f5d38d; + background: var(--warning-bg); + color: var(--warning); +} + +.context-confidence-unsupported { + border-color: #c3c9d1; + background: #f2f4f7; + color: var(--draft); +} + +.context-source-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.context-source-list a { + display: inline-flex; + align-items: center; + gap: 7px; + border: 1px solid #9bd3ca; + border-radius: 999px; + background: var(--surface); + color: var(--accent-strong); + font-size: 0.84rem; + font-weight: 760; + line-height: 1.2; + padding: 7px 10px; +} + +.context-source-list a:hover, +.context-source-list a:focus-visible { + outline: 3px solid var(--focus-ring); + outline-offset: 1px; +} + +.context-source-list span { + color: var(--muted); + font-size: 0.72rem; + text-transform: uppercase; +} + +.context-question-suggestions { + display: grid; + gap: 6px; +} + +.context-question-suggestions ul { + margin: 0; + padding-left: 18px; +} + .demo-label { display: inline-flex; width: fit-content; @@ -352,9 +585,9 @@ p { h1 { max-width: 780px; - margin-bottom: 14px; - font-size: clamp(2.15rem, 4vw, 4rem); - line-height: 1.02; + margin-bottom: 10px; + font-size: clamp(2rem, 3vw, 2.75rem); + line-height: 1.08; letter-spacing: 0; } @@ -480,6 +713,17 @@ h3 { margin-top: 24px; } +.overview-kpi-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-top: 18px; +} + +.overview-kpi-strip .metric-card { + min-height: 112px; +} + .api-connection-banner { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(320px, 1.1fr); @@ -861,201 +1105,837 @@ h3 { gap: 14px; } -.timeline-heading h3 { +.timeline-heading h3 { + margin-bottom: 0; +} + +.timeline-heading time { + flex: 0 0 auto; + color: var(--muted); + font-size: 0.84rem; + font-weight: 650; +} + +.evidence-type { + display: inline-flex; + width: fit-content; + margin-bottom: 7px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-muted); + color: #405263; + font-size: 0.74rem; + font-weight: 760; + line-height: 1; + padding: 6px 7px; + text-transform: uppercase; +} + +.evidence-process-signal .evidence-type { + border-color: #9bd3ca; + background: var(--accent-bg); + color: var(--accent-strong); +} + +.evidence-quality-result .evidence-type { + border-color: #f5d38d; + background: var(--warning-bg); + color: #7a4f0f; +} + +.evidence-correlation-window .evidence-type { + border-color: #b9c1dc; + background: var(--info-bg); + color: #35436f; +} + +.evidence-meta { + display: grid; + grid-template-columns: minmax(140px, 0.3fr) minmax(0, 0.7fr); + gap: 10px; + margin: 0; +} + +.evidence-meta div { + display: grid; + gap: 4px; + border-top: 1px solid var(--border); + padding-top: 10px; +} + +.evidence-meta dt { + color: var(--muted); + font-size: 0.74rem; + font-weight: 760; + text-transform: uppercase; +} + +.evidence-meta dd { + margin: 0; + overflow-wrap: anywhere; + font-weight: 700; + line-height: 1.45; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin: 0; +} + +.metric-grid div { + display: grid; + gap: 4px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--surface); + padding: 12px; +} + +.metric-grid dt { + color: var(--muted); + font-size: 0.76rem; + font-weight: 760; + text-transform: uppercase; +} + +.metric-grid dd { + margin: 0; + overflow-wrap: anywhere; + font-weight: 720; +} + +.inline-status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.meta-line { + color: var(--muted); + font-size: 0.86rem; +} + +.state-panel { + display: grid; + gap: 5px; + padding: 16px; +} + +.state-panel span { + color: var(--muted); + line-height: 1.5; +} + +.error-panel { + border-color: #f2b6b6; + background: var(--danger-bg); +} + +.error-panel strong { + color: var(--danger); +} + +.missing-data-panel { + border-color: #f5d38d; + background: var(--warning-bg); +} + +.missing-data-panel strong { + color: var(--warning); +} + +.decision-note { + border-left: 4px solid var(--accent); + background: #e7f3f1; + color: #21423e; + line-height: 1.5; + padding: 16px; +} + +.recommendation-review-panel { + gap: 20px; +} + +.review-form { + display: grid; + gap: 14px; +} + +.form-help { + margin-bottom: 0; + color: var(--muted); + font-size: 0.9rem; + line-height: 1.5; +} + +.review-form div { + display: grid; + gap: 7px; +} + +.review-form label { + color: var(--muted); + font-size: 0.78rem; + font-weight: 760; + text-transform: uppercase; +} + +.review-form input, +.review-form textarea { + width: 100%; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--surface); + color: var(--text); + font: inherit; + line-height: 1.5; + padding: 11px 12px; +} + +.review-form input:focus, +.review-form textarea:focus { + border-color: var(--accent); + outline: 3px solid var(--focus-ring); + outline-offset: 1px; +} + +.overview-dashboard { + display: grid; + gap: 16px; +} + +.overview-dashboard h1 { + max-width: none; + margin-bottom: 5px; + font-size: clamp(1.55rem, 2.4vw, 2.15rem); + line-height: 1.15; +} + +.overview-dashboard h2 { + margin-bottom: 0; + font-size: 1.08rem; +} + +.overview-dashboard p { + margin-bottom: 0; +} + +.overview-commandbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.overview-commandbar p { + max-width: 680px; + color: var(--muted); + line-height: 1.45; +} + +.overview-commandbar-actions, +.overview-action-row { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} + +.overview-dashboard .context-question-panel { + margin: 0; +} + +.overview-dashboard .context-question-form { + grid-template-columns: 1fr; +} + +.overview-dashboard .context-question-form div { + border: 1px solid var(--border); + border-radius: 8px; + background: #ffffff; + padding: 0; +} + +.overview-dashboard .context-question-form input { + min-height: 48px; + border: 0; + border-radius: 8px 0 0 8px; + padding-left: 16px; +} + +.overview-dashboard .context-question-form button { + min-width: 94px; + border-radius: 0 7px 7px 0; +} + +.overview-summary-row { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.overview-summary-row article, +.factory-context-card, +.priority-case-card, +.overview-side-card { + border: 1px solid var(--border); + border-radius: 8px; + background: #ffffff; + box-shadow: var(--shadow); +} + +.overview-summary-row article { + display: grid; + min-height: 98px; + gap: 5px; + align-content: start; + padding: 16px; +} + +.overview-summary-row strong { + color: var(--text); + font-size: clamp(1.35rem, 2.2vw, 1.95rem); + line-height: 1.05; +} + +.overview-summary-row small { + color: var(--muted); + font-size: 0.82rem; + font-weight: 650; +} + +.overview-main-grid { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.8fr); + gap: 14px; + align-items: start; +} + +.factory-context-card { + display: grid; + min-height: 520px; + gap: 12px; + padding: 18px; +} + +.factory-context-card .section-heading { + align-items: center; +} + +.factory-context-card .secondary-action { + flex: 0 0 auto; +} + +.factory-context-graph { + position: relative; + min-height: 430px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 8px; + background: + radial-gradient(circle at 18px 18px, rgb(82 101 121 / 10%) 1px, transparent 1px), + #fbfcfd; + background-size: 34px 34px; +} + +.factory-context-graph svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.factory-context-graph line { + stroke: #b7c5d0; + stroke-width: 0.32; +} + +.factory-graph-node { + position: absolute; + display: grid; + min-width: 118px; + max-width: 180px; + justify-items: center; + gap: 5px; + color: #46596b; + text-align: center; + transform: translate(-50%, -50%); +} + +.factory-graph-node span { + width: 15px; + height: 15px; + border-radius: 999px; + background: #58616a; + box-shadow: 0 0 0 5px rgb(88 97 106 / 8%); +} + +.factory-graph-node strong { + color: #3c4d5d; + font-size: 0.88rem; + line-height: 1.2; +} + +.factory-graph-node small { + color: var(--muted); + font-size: 0.72rem; + font-weight: 720; + text-transform: uppercase; +} + +.factory-graph-node.site { + top: 16%; + left: 48%; +} + +.factory-graph-node.area { + top: 34%; + left: 38%; +} + +.factory-graph-node.asset { + top: 52%; + left: 48%; +} + +.factory-graph-node.signal { + top: 66%; + left: 60%; +} + +.factory-graph-node.case { + top: 70%; + left: 36%; +} + +.factory-graph-node.work-order { + top: 48%; + left: 71%; +} + +.factory-graph-node.recommendation { + top: 76%; + left: 79%; +} + +.factory-graph-node.historian { + top: 52%; + left: 22%; +} + +.factory-graph-node.qms { + top: 78%; + left: 18%; +} + +.factory-graph-node.mes { + top: 35%; + left: 86%; +} + +.factory-graph-node.asset span, +.factory-graph-node.case span, +.factory-graph-node.recommendation span { + background: var(--accent); +} + +.priority-case-card, +.overview-side-card { + display: grid; + gap: 14px; + padding: 18px; +} + +.priority-case-card h2 { + font-size: 1.18rem; + line-height: 1.25; +} + +.compact-detail-list { + display: grid; + gap: 10px; + margin: 0; +} + +.compact-detail-list div { + display: grid; + gap: 4px; + border-top: 1px solid var(--border); + padding-top: 10px; +} + +.compact-detail-list dt { + color: var(--muted); + font-size: 0.72rem; + font-weight: 800; + text-transform: uppercase; +} + +.compact-detail-list dd { + margin: 0; + color: var(--text); + font-size: 0.94rem; + font-weight: 700; + line-height: 1.38; +} + +.sentinel-console { + display: grid; + gap: 16px; +} + +.sentinel-commandbar, +.sentinel-card-heading, +.sentinel-finding-header, +.sentinel-action-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.sentinel-commandbar { + align-items: center; +} + +.sentinel-commandbar h1 { + margin-bottom: 0; +} + +.sentinel-commandbar-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} + +.toolbar-button, +.sentinel-tune-link { + display: inline-flex; + min-height: 38px; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 7px; + background: #ffffff; + color: var(--text); + cursor: pointer; + font: inherit; + font-size: 0.9rem; + font-weight: 720; + line-height: 1; + padding: 10px 13px; +} + +.toolbar-button-primary { + border-color: var(--accent); + background: var(--accent); + color: #ffffff; +} + +.toolbar-button:hover, +.toolbar-button:focus-visible, +.sentinel-tune-link:hover, +.sentinel-tune-link:focus-visible { + border-color: var(--accent); + outline: 3px solid var(--focus-ring); + outline-offset: 2px; +} + +.sentinel-search-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; +} + +.sentinel-search-field input { + width: 100%; + min-height: 46px; + border: 1px solid var(--border); + border-radius: 7px; + background: #ffffff; + color: var(--text); + font: inherit; + padding: 12px 14px; +} + +.sentinel-stage-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + overflow: hidden; + border: 1px solid var(--border); + border-radius: 8px; + background: #ffffff; +} + +.sentinel-stage-strip article { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 2px 12px; + align-items: center; + border-right: 1px solid var(--border); + padding: 18px 22px; +} + +.sentinel-stage-strip article:last-child { + border-right: 0; +} + +.sentinel-stage-strip article.active { + box-shadow: inset 0 -4px 0 var(--accent); +} + +.sentinel-stage-strip strong { + display: inline-flex; + width: 36px; + height: 36px; + grid-row: span 2; + align-items: center; + justify-content: center; + border-radius: 999px; + background: #e8edf3; + color: var(--text); +} + +.sentinel-stage-strip .active strong { + background: var(--accent); + color: #ffffff; +} + +.sentinel-stage-strip span { + font-weight: 800; +} + +.sentinel-stage-strip small { + color: var(--muted); + font-size: 0.84rem; + font-weight: 650; +} + +.sentinel-workbench-grid { + display: grid; + grid-template-columns: minmax(420px, 0.92fr) minmax(0, 1.18fr); + gap: 8px; +} + +.sentinel-queue-card, +.sentinel-finding-card, +.sentinel-recommendation-card { + min-width: 0; + border: 1px solid var(--border); + border-radius: 8px; + background: #ffffff; + box-shadow: var(--shadow); +} + +.sentinel-queue-card { + display: grid; + align-content: start; + overflow: hidden; +} + +.sentinel-card-heading { + align-items: center; + border-bottom: 1px solid var(--border); + padding: 14px 16px; +} + +.sentinel-card-heading h2, +.sentinel-card-heading h3 { margin-bottom: 0; + font-size: 1rem; } -.timeline-heading time { - flex: 0 0 auto; +.sentinel-card-heading label { + display: flex; + align-items: center; + gap: 8px; color: var(--muted); font-size: 0.84rem; - font-weight: 650; + font-weight: 700; } -.evidence-type { - display: inline-flex; - width: fit-content; - margin-bottom: 7px; +.sentinel-card-heading select { border: 1px solid var(--border); - border-radius: 6px; - background: var(--surface-muted); - color: #405263; - font-size: 0.74rem; - font-weight: 760; - line-height: 1; - padding: 6px 7px; - text-transform: uppercase; + border-radius: 7px; + background: #ffffff; + color: var(--text); + font: inherit; + padding: 8px 28px 8px 10px; } -.evidence-process-signal .evidence-type { - border-color: #9bd3ca; - background: var(--accent-bg); - color: var(--accent-strong); +.sentinel-table-wrap { + overflow-x: auto; } -.evidence-quality-result .evidence-type { - border-color: #f5d38d; - background: var(--warning-bg); - color: #7a4f0f; +.sentinel-table, +.sentinel-evidence-table { + width: 100%; + min-width: 620px; + border-collapse: collapse; } -.evidence-correlation-window .evidence-type { - border-color: #b9c1dc; - background: var(--info-bg); - color: #35436f; +.sentinel-table th, +.sentinel-table td, +.sentinel-evidence-table th, +.sentinel-evidence-table td { + border-bottom: 1px solid var(--border); + padding: 12px 14px; + text-align: left; + vertical-align: top; } -.evidence-meta { - display: grid; - grid-template-columns: minmax(140px, 0.3fr) minmax(0, 0.7fr); - gap: 10px; - margin: 0; +.sentinel-table th, +.sentinel-evidence-table th { + background: var(--surface-muted); + color: var(--muted); + font-size: 0.74rem; + font-weight: 800; + text-transform: uppercase; } -.evidence-meta div { - display: grid; - gap: 4px; - border-top: 1px solid var(--border); - padding-top: 10px; +.sentinel-table td { + font-size: 0.9rem; + line-height: 1.35; } -.evidence-meta dt { - color: var(--muted); - font-size: 0.74rem; +.sentinel-table tr.selected td { + background: #e8f7f6; + box-shadow: inset 4px 0 0 var(--accent); +} + +.sentinel-table a { font-weight: 760; - text-transform: uppercase; } -.evidence-meta dd { - margin: 0; - overflow-wrap: anywhere; - font-weight: 700; - line-height: 1.45; +.sentinel-status-dot { + display: inline-block; + width: 8px; + height: 8px; + margin-right: 8px; + border-radius: 999px; + background: var(--accent); } -.metric-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); +.sentinel-pagination { + display: flex; + justify-content: space-between; gap: 12px; - margin: 0; + color: var(--muted); + font-size: 0.86rem; + padding: 14px 16px; } -.metric-grid div { +.sentinel-detail-stack { display: grid; - gap: 4px; - border: 1px solid var(--border); - border-radius: 7px; - background: var(--surface); - padding: 12px; + gap: 10px; + min-width: 0; } -.metric-grid dt { - color: var(--muted); - font-size: 0.76rem; - font-weight: 760; - text-transform: uppercase; +.sentinel-finding-card, +.sentinel-recommendation-card { + display: grid; + gap: 16px; + padding: 18px; } -.metric-grid dd { - margin: 0; - overflow-wrap: anywhere; - font-weight: 720; +.sentinel-finding-header h2 { + margin: 4px 0 0; + font-size: 1.08rem; } -.inline-status { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; +.sentinel-finding-meta { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; + margin: 0; } -.meta-line { +.sentinel-finding-meta dt { color: var(--muted); - font-size: 0.86rem; + font-size: 0.72rem; + font-weight: 800; + text-transform: uppercase; } -.state-panel { - display: grid; - gap: 5px; - padding: 16px; +.sentinel-finding-meta dd { + margin: 5px 0 0; + color: var(--text); + font-size: 0.88rem; + font-weight: 700; } -.state-panel span { +.sentinel-finding-card p, +.sentinel-recommendation-card p { + margin-bottom: 0; color: var(--muted); line-height: 1.5; } -.error-panel { - border-color: #f2b6b6; - background: var(--danger-bg); -} - -.error-panel strong { - color: var(--danger); +.sentinel-tabs { + display: flex; + flex-wrap: wrap; + gap: 18px; + border-bottom: 1px solid var(--border); } -.missing-data-panel { - border-color: #f5d38d; - background: var(--warning-bg); +.sentinel-tabs span { + color: var(--muted); + font-size: 0.88rem; + font-weight: 700; + padding-bottom: 11px; } -.missing-data-panel strong { - color: var(--warning); +.sentinel-tabs span.active { + border-bottom: 3px solid var(--accent); + color: var(--accent-strong); } -.decision-note { - border-left: 4px solid var(--accent); - background: #e7f3f1; - color: #21423e; - line-height: 1.5; - padding: 16px; +.sentinel-evidence-panel { + display: grid; + gap: 12px; } -.recommendation-review-panel { - gap: 20px; +.sentinel-evidence-table { + min-width: 560px; } -.review-form { - display: grid; - gap: 14px; +.sentinel-evidence-table td strong, +.sentinel-evidence-table td span { + display: block; } -.form-help { - margin-bottom: 0; +.sentinel-evidence-table td span { + margin-top: 4px; color: var(--muted); - font-size: 0.9rem; - line-height: 1.5; + font-size: 0.84rem; + line-height: 1.35; } -.review-form div { +.sentinel-recommendation-grid { display: grid; - gap: 7px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; } -.review-form label { - color: var(--muted); - font-size: 0.78rem; - font-weight: 760; - text-transform: uppercase; +.sentinel-recommendation-grid section:first-child { + border-right: 1px solid var(--border); + padding-right: 18px; } -.review-form input, -.review-form textarea { - width: 100%; - border: 1px solid var(--border); - border-radius: 7px; - background: var(--surface); - color: var(--text); - font: inherit; - line-height: 1.5; - padding: 11px 12px; +.sentinel-recommendation-grid h3 { + margin: 0 0 6px; + font-size: 0.9rem; } -.review-form input:focus, -.review-form textarea:focus { - border-color: var(--accent); - outline: 3px solid var(--focus-ring); - outline-offset: 1px; +.sentinel-action-row { + justify-content: flex-start; + flex-wrap: wrap; + border-top: 1px solid var(--border); + padding-top: 14px; } .connections-workspace { @@ -1877,9 +2757,51 @@ code { border-bottom: 1px solid var(--border); } - .status-strip { + .operator-topbar { + display: grid; + padding: 12px 16px; + } + + .topbar-actions { + flex-wrap: wrap; + } + + .sentinel-commandbar, + .sentinel-card-heading, + .sentinel-finding-header { + display: grid; + } + + .sentinel-search-row, + .sentinel-workbench-grid, + .sentinel-recommendation-grid, + .overview-commandbar, + .overview-main-grid { + grid-template-columns: 1fr; + } + + .overview-commandbar { + display: grid; + } + + .overview-commandbar-actions { + justify-content: flex-start; + } + + .overview-summary-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .sentinel-stage-strip, + .sentinel-finding-meta { grid-template-columns: repeat(2, minmax(0, 1fr)); - margin-top: 0; + } + + .sentinel-recommendation-grid section:first-child { + border-right: 0; + border-bottom: 1px solid var(--border); + padding-right: 0; + padding-bottom: 16px; } .hero { @@ -1914,6 +2836,10 @@ code { grid-template-columns: 1fr; } + .overview-kpi-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .context-panel, .detection-panel { grid-column: auto; @@ -1935,11 +2861,11 @@ code { @media (max-width: 1340px) { .page-shell { - width: min(1100px, calc(100% - 28px)); + width: min(1180px, calc(100% - 28px)); } h1 { - font-size: clamp(2rem, 3vw, 3.2rem); + font-size: clamp(1.9rem, 3vw, 2.5rem); } .hero-copy { @@ -1958,6 +2884,18 @@ code { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .sentinel-workbench-grid { + grid-template-columns: minmax(360px, 0.9fr) minmax(0, 1.1fr); + } + + .sentinel-finding-meta { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .overview-main-grid { + grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr); + } + .source-search-field { grid-column: 1 / -1; } @@ -1967,15 +2905,41 @@ code { } } +@media (max-width: 880px) { + .sentinel-workbench-grid, + .sentinel-recommendation-grid, + .overview-main-grid { + grid-template-columns: 1fr; + } + + .sentinel-finding-meta { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + @media (max-width: 560px) { .operator-sidebar { padding: 18px 16px; } - .status-strip { + .page-shell { + width: min(100% - 20px, 100%); + } + + .sentinel-stage-strip, + .sentinel-finding-meta { grid-template-columns: 1fr; } + .sentinel-stage-strip article { + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .sentinel-stage-strip article:last-child { + border-bottom: 0; + } + .content-grid { grid-template-columns: 1fr; } @@ -1993,6 +2957,30 @@ code { grid-template-columns: 1fr; } + .overview-kpi-strip { + grid-template-columns: 1fr; + } + + .overview-summary-row { + grid-template-columns: 1fr; + } + + .factory-context-card { + min-height: 460px; + } + + .factory-context-graph { + min-height: 360px; + } + + .factory-graph-node { + min-width: 94px; + } + + .factory-graph-node strong { + font-size: 0.78rem; + } + .diagnostics-card-grid { grid-template-columns: 1fr; } @@ -2014,6 +3002,10 @@ code { width: 100%; } + .context-question-form div { + display: grid; + } + .api-connection-details { grid-template-columns: 1fr; } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index cd1a70f..e5a2d7d 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import type { ReactNode } from "react"; -import { getApiBaseUrl } from "../lib/api-client"; +import { OperatorNavigation } from "./components/operator-navigation"; import "./globals.css"; export const metadata: Metadata = { @@ -10,35 +10,6 @@ export const metadata: Metadata = { description: "External-source Factory Intelligence Platform workbench shell.", }; -type NavItem = - | { href: string; label: string } - | { label: string; status: "Planned" }; - -type NavGroup = { - items: NavItem[]; - label: string; -}; - -const navGroups: NavGroup[] = [ - { - items: [ - { href: "/", label: "Overview" }, - { href: "/detections", label: "Detections" }, - { href: "/recommendations", label: "Recommendations" }, - { href: "/rca-capa-draft", label: "RCA/CAPA Draft" }, - ], - label: "Sentinel workflows", - }, - { - items: [ - { href: "/connections", label: "Connections" }, - { href: "/protocol-diagnostics", label: "Protocol Diagnostics" }, - { href: "/tag-source-browser", label: "Tag/Source Browser" }, - ], - label: "Protocol operations", - }, -]; - export default function RootLayout({ children }: { children: ReactNode }) { return ( @@ -50,59 +21,41 @@ export default function RootLayout({ children }: { children: ReactNode }) {
+
+
+
+ + Configure Connections + + + Plant 7 - Northview + + + + + AS + +
+
{children}
+
+ Time Zone: America/Chicago (CDT) + (c) 2026 Factory Intelligence Platform + v2.8.1 +
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 34d2886..a850b3d 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,21 +1,19 @@ import Link from "next/link"; -import { - ApiConnectionBanner, - ApiErrorPanel, - StatusBadge, -} from "./components/demo-state"; +import { ApiErrorPanel, StatusBadge } from "./components/demo-state"; +import { ContextQuestionPanel } from "./context-question-panel"; import { type Area, type Batch, type Detection, type Equipment, + type ProcessSignal, type Recommendation, type Site, formatApiError, - getApiBaseUrl, workbenchApi, } from "../lib/api-client"; +import { productCopy } from "../lib/product-copy"; export const dynamic = "force-dynamic"; @@ -36,153 +34,248 @@ export default async function OverviewPage() { const overview = await loadOverview(); return ( - <> -
-
-

{overview.ok ? overview.context.siteName : "Operations Workbench"}

-

- {overview.ok - ? overview.context.siteDescription - : "The manufacturer demo overview needs the local FIP API to show current factory context."} -

-
- - Open detection - -
+
+
+
+ Operations Overview +

{overview.ok ? overview.context.siteName : productCopy.siteName}

+

{overview.ok ? overview.context.siteDescription : productCopy.siteSubtitle}

+
+
+ + Configure Connections + + + Open Process Sentinel +
- -
+ - {overview.ok ? ( - - ) : null} + {!overview.ok ? : null} {overview.ok ? ( -
-
- Current demo context -

{overview.context.areaName}

-
-
-
Line
-
{overview.context.lineDescription}
-
-
-
Asset
-
{overview.context.assetName}
-
-
-
Work order
-
{overview.context.workOrderId}
-
-
-
Product
-
{overview.context.productName}
-
-
-
- -
-
- Active detections + <> +
+
+ Active Cases {overview.activeDetections.length} -

Open Process Sentinel cases from external-source event data.

+ Process Sentinel
-
- Pending recommendations +
+ Pending Reviews {overview.pendingRecommendations.length} -

Human-reviewed recommendations awaiting a demo decision.

+ Human disposition +
+
+ Data Health + {overview.dataHealthLabel} + {productCopy.dataSourceLabel} +
+
+ Last Updated + {overview.context.lastUpdated} + Current session +
+
+ +
+
+
+
+ Factory Context +

Connected operational graph

+
+ + Browse sources + +
+
-
-
-
- Most important detection +
+ Priority Case {overview.importantDetection ? ( <>

{overview.importantDetection.summary}

-
- - - +
+
+
Severity
+
+ +
+
+
+
Status
+
+ +
+
+
+
Confidence
+
{Math.round(overview.importantDetection.confidence * 100)}%
+
+
+
Work order
+
{overview.context.workOrderId}
+
+
+
+ + Review case + + + Review recommendation +
) : ( <> -

No active detections

-

- Start Demo-Factory, then start the FIP Docker Compose stack - and refresh this page after Process Sentinel stores runtime - detections. -

+

No active cases

+

No active Process Sentinel cases are available in the current operational state.

+ + Open Process Sentinel + )} -
- - Open detection - -
-
+ + +
+ Operational Context +

{overview.context.areaName}

+
+
+
Line
+
{overview.context.lineDescription}
+
+
+
Asset
+
{overview.context.assetName}
+
+
+
Signal
+
{overview.context.signalName}
+
+
+
Product
+
{overview.context.productName}
+
+
+
+ +
+ Integration & Governance +
+
+
Data source
+
{productCopy.dataSourceLabel}
+
+
+
Status
+
{productCopy.integrationStatusLabel}
+
+
+
Writeback policy
+
{productCopy.writebackPolicyLabel}
+
+
+
External submission
+
Not configured
+
+
+
+
+ ) : null} - + + ); +} + +function FactoryContextGraph({ context }: { context: OverviewContext }) { + const nodes = [ + { className: "site", label: context.siteName, type: "Site" }, + { className: "area", label: context.areaName, type: "Area" }, + { className: "asset", label: context.assetName, type: "Asset" }, + { className: "signal", label: context.signalName, type: "Signal" }, + { className: "case", label: "Priority case", type: "Process Sentinel" }, + { className: "work-order", label: context.workOrderId, type: "Work order" }, + { className: "recommendation", label: "Recommendation", type: "Governed review" }, + { className: "historian", label: "Historian", type: "Connection" }, + { className: "qms", label: "QMS / LIMS", type: "Quality" }, + { className: "mes", label: "MES", type: "Production" }, + ]; + + return ( +
+ + {nodes.map((node) => ( +
+ + {node.label} + {node.type} +
+ ))} +
); } +type OverviewContext = { + areaName: string; + assetName: string; + lastUpdated: string; + lineDescription: string; + productName: string; + signalName: string; + siteDescription: string; + siteName: string; + workOrderId: string; +}; + async function loadOverview() { try { - const [health, sites, areas, equipment, batches, detections, recommendations] = - await Promise.all([ - workbenchApi.getHealth(), - workbenchApi.listSites(), - workbenchApi.listAreas(), - workbenchApi.listEquipment(), - workbenchApi.listBatches(), - workbenchApi.listDetections(), - workbenchApi.listRecommendations(), - ]); + const [ + health, + sites, + areas, + equipment, + processSignals, + batches, + detections, + recommendations, + ] = await Promise.all([ + workbenchApi.getHealth(), + workbenchApi.listSites(), + workbenchApi.listAreas(), + workbenchApi.listEquipment(), + workbenchApi.listProcessSignals(), + workbenchApi.listBatches(), + workbenchApi.listDetections(), + workbenchApi.listRecommendations(), + ]); const activeDetections = detections.filter((detection) => activeDetectionStatuses.has(detection.status), ); @@ -193,8 +286,15 @@ async function loadOverview() { return { activeDetections, - context: buildOverviewContext({ areas, batches, equipment, importantDetection, sites }), - health, + context: buildOverviewContext({ + areas, + batches, + equipment, + importantDetection, + processSignals, + sites, + }), + dataHealthLabel: health.status === "ok" ? productCopy.dataHealthLabel : health.status, importantDetection, ok: true as const, pendingRecommendations, @@ -207,8 +307,8 @@ async function loadOverview() { function selectImportantDetection(detections: Detection[]): Detection | null { const severityRank: Record = { high: 3, - medium: 2, low: 1, + medium: 2, }; return ( @@ -227,19 +327,24 @@ function buildOverviewContext({ batches, equipment, importantDetection, + processSignals, sites, }: { areas: Area[]; batches: Batch[]; equipment: Equipment[]; importantDetection: Detection | null; + processSignals: ProcessSignal[]; sites: Site[]; -}) { +}): OverviewContext { const currentBatch = batches.find((batch) => batch.status === "running" || batch.status === "held") ?? batches[0]; const relatedAsset = importantDetection?.related_asset_ids[0]; const currentEquipment = equipment.find((asset) => asset.equipment_id === relatedAsset) ?? equipment[0]; + const currentSignal = + processSignals.find((signal) => signal.equipment_id === currentEquipment?.equipment_id) ?? + processSignals[0]; const currentArea = areas.find((area) => area.area_id === currentEquipment?.area_id) ?? areas.find((area) => area.area_id === currentBatch?.area_id) ?? @@ -250,18 +355,24 @@ function buildOverviewContext({ sites[0]; return { - areaName: currentArea?.name ?? "Demo production area", - assetName: currentEquipment?.name ?? "Demo asset unavailable", - lineDescription: currentArea?.description ?? "Demo line context unavailable", - productName: currentBatch?.product_name ?? "Demo product unavailable", - siteDescription: - currentSite?.description ?? - "External-source site context will appear when the local FIP API is available.", - siteName: currentSite?.name ?? "Factory Intelligence Platform demo", - workOrderId: importantDetection?.related_work_order_id ?? "Demo work order unavailable", + areaName: currentArea?.name ?? "Production area unavailable", + assetName: currentEquipment?.name ?? "Asset unavailable", + lastUpdated: importantDetection?.created_at + ? formatTimestamp(importantDetection.created_at) + : "Current session", + lineDescription: currentArea?.description ?? "Line context unavailable", + productName: currentBatch?.product_name ?? "Product unavailable", + signalName: currentSignal?.name ?? "Signal unavailable", + siteDescription: currentSite?.description ?? productCopy.siteSubtitle, + siteName: currentSite?.name ?? productCopy.siteName, + workOrderId: importantDetection?.related_work_order_id ?? "Work order unavailable", }; } +function formatTimestamp(value: string): string { + return value.replace("T", " ").replace("Z", " UTC"); +} + function severityTone(severity: Detection["severity"]) { if (severity === "high") { return "danger"; diff --git a/apps/web/app/process-sentinel/page.tsx b/apps/web/app/process-sentinel/page.tsx new file mode 100644 index 0000000..5f65f4b --- /dev/null +++ b/apps/web/app/process-sentinel/page.tsx @@ -0,0 +1,396 @@ +import Link from "next/link"; + +import { + ApiErrorPanel, + EmptyState, + StatusBadge, +} from "../components/demo-state"; +import { + type Detection, + type Equipment, + type EvidenceItem, + type Recommendation, + formatApiError, + workbenchApi, +} from "../../lib/api-client"; + +export const dynamic = "force-dynamic"; + +export default async function ProcessSentinelPage() { + const result = await loadProcessSentinelWorkbench(); + + return ( +
+
+
+

Process Sentinel

+
+
+ + + + Configure Connections + +
+
+ +
+ + + Source Health + +
+ +
+
+ 1 + Detect + {result.ok ? `${result.newCount} new` : "Unavailable"} +
+
+ 2 + Explain + {result.ok ? `${result.explainCount} explained` : "Unavailable"} +
+
+ 3 + Review + {result.ok ? `${result.reviewCount} pending` : "Unavailable"} +
+
+ 4 + Draft + {result.ok ? `${result.draftCount} in progress` : "Unavailable"} +
+
+ + {!result.ok ? : null} + + {result.ok && result.detections.length === 0 ? ( + + ) : null} + + {result.ok && result.selectedDetection ? ( +
+
+
+

Detection Queue ({result.detections.length})

+ +
+
+ + + + + + + + + + + + {result.detections.map((detection) => { + const isSelected = + detection.detection_id === result.selectedDetection?.detection_id; + + return ( + + + + + + + + ); + })} + +
SeverityDetection TitleUnitDetected AtStatus
+ + + + {detection.summary} + + {formatAssetLabel(detection, result.equipmentById)}{formatClock(detection.created_at)} +
+
+
+ 1-{result.detections.length} of {result.detections.length} + Rows per page 10 +
+
+ +
+
+
+
+ Selected Finding +

{result.selectedDetection.summary}

+
+ +
+
+
+
Unit
+
{formatAssetLabel(result.selectedDetection, result.equipmentById)}
+
+
+
Detected At
+
{formatDateTime(result.selectedDetection.created_at)}
+
+
+
Duration
+
{formatDuration(result.selectedDetection)}
+
+
+
Status
+
{formatStatus(result.selectedDetection.status)}
+
+
+
Owner
+
Unassigned
+
+
+

+ {result.selectedDetection.summary} Confidence is{" "} + {Math.round(result.selectedDetection.confidence * 100)}% for the + current operating window. +

+ +
+
+

Evidence Timeline

+ + Open Trend + +
+ {result.evidence.length > 0 ? ( + + + + + + + + + + + {result.evidence.map((item) => ( + + + + + + + ))} + +
TimeEvidenceScoreSeverity
{formatClock(item.timestamp)} + {item.title} + {item.description} + {Math.round(item.score * 100)}% + +
+ ) : ( +

No evidence items are linked to this finding yet.

+ )} +
+
+ +
+
+
+ Governed Recommendation +

{result.selectedRecommendation ? "Recommendation Draft" : "No recommendation linked"}

+
+ +
+ {result.selectedRecommendation ? ( +
+
+

Recommendation

+

{result.selectedRecommendation.recommended_action}

+

Expected Outcome

+

+ Reduce quality risk while the affected work order is under + human review. +

+
+
+

Rationale

+

{result.selectedRecommendation.rationale}

+

Reference Evidence

+

{result.selectedRecommendation.evidence_ids.join(", ")}

+
+
+ ) : ( +

No governed recommendation has been generated for this case yet.

+ )} +
+ + Review & Edit + + + Request More Analysis + + + Configure Connections + +
+
+
+
+ ) : null} +
+ ); +} + +async function loadProcessSentinelWorkbench() { + try { + const [detections, recommendations, equipment] = await Promise.all([ + workbenchApi.listDetections(), + workbenchApi.listRecommendations(), + workbenchApi.listEquipment(), + ]); + const selectedDetection = selectDetection(detections); + const evidence = selectedDetection + ? await workbenchApi.listDetectionEvidence(selectedDetection.detection_id) + : []; + const selectedRecommendation = selectedDetection + ? recommendations.find( + (recommendation) => + recommendation.detection_id === selectedDetection.detection_id, + ) ?? null + : null; + + return { + detections, + draftCount: recommendations.filter((item) => item.status === "draft").length, + equipmentById: new Map(equipment.map((item) => [item.equipment_id, item])), + evidence, + explainCount: detections.filter((item) => item.status === "investigating").length, + newCount: detections.filter((item) => item.status === "new").length, + ok: true as const, + reviewCount: recommendations.filter((item) => item.status === "needs_review").length, + selectedDetection, + selectedRecommendation, + }; + } catch (error) { + return { message: formatApiError(error), ok: false as const }; + } +} + +function selectDetection(detections: Detection[]): Detection | null { + return [...detections].sort((left, right) => { + const severityDelta = severityRank(right.severity) - severityRank(left.severity); + if (severityDelta !== 0) { + return severityDelta; + } + return Date.parse(right.created_at) - Date.parse(left.created_at); + })[0] ?? null; +} + +function severityRank(severity: Detection["severity"] | EvidenceItem["severity"]) { + if (severity === "high") { + return 3; + } + if (severity === "medium") { + return 2; + } + return 1; +} + +function severityTone(severity: Detection["severity"] | EvidenceItem["severity"]) { + if (severity === "high") { + return "danger"; + } + if (severity === "medium") { + return "warning"; + } + return "info"; +} + +function formatAssetLabel( + detection: Detection, + equipmentById: Map, +): string { + const assetId = detection.related_asset_ids[0]; + return assetId ? equipmentById.get(assetId)?.name ?? assetId : "Unassigned"; +} + +function formatStatus(status: Detection["status"]): string { + if (status === "recommendation_created") { + return "Review"; + } + if (status === "investigating") { + return "Explain"; + } + return status.replaceAll("_", " "); +} + +function formatClock(value: string): string { + return new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + hour12: false, + minute: "2-digit", + timeZone: "UTC", + }).format(new Date(value)); +} + +function formatDateTime(value: string): string { + return new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "UTC", + }).format(new Date(value)); +} + +function formatDuration(detection: Detection): string { + const start = Date.parse(detection.time_window_start); + const end = Date.parse(detection.time_window_end); + if (Number.isNaN(start) || Number.isNaN(end) || end <= start) { + return "Current window"; + } + return `${Math.round((end - start) / 60_000)} min`; +} diff --git a/apps/web/app/rca-capa-draft/draft-copy-button.tsx b/apps/web/app/rca-capa-draft/draft-copy-button.tsx index 68f3682..28b9143 100644 --- a/apps/web/app/rca-capa-draft/draft-copy-button.tsx +++ b/apps/web/app/rca-capa-draft/draft-copy-button.tsx @@ -21,7 +21,7 @@ export function DraftCopyButton({ draftText }: DraftCopyButtonProps) { return (
{copyState === "copied" ? "Copied" : null} diff --git a/apps/web/app/rca-capa-draft/page.tsx b/apps/web/app/rca-capa-draft/page.tsx index 3b1cce1..c7764e6 100644 --- a/apps/web/app/rca-capa-draft/page.tsx +++ b/apps/web/app/rca-capa-draft/page.tsx @@ -10,7 +10,6 @@ import { ApiClientError, type RcaCapaDraft, formatApiError, - getApiBaseUrl, workbenchApi, } from "../../lib/api-client"; import { DraftCopyButton } from "./draft-copy-button"; @@ -31,22 +30,21 @@ export default async function RcaCapaDraftPage({ return (
- Demo-generated draft + Investigation draft

RCA/CAPA Draft

- Preview investigation-ready RCA/CAPA draft language for the selected - external-source Process Sentinel detection. Draft content is - human-review required and is not automatically submitted to QMS or MES - systems. + Review RCA/CAPA draft language generated from the selected case, + linked evidence, and recommendation history. Draft content requires + human review before QMS or MES submission.

{!result.ok && result.notFound ? ( - The local FIP API did not return an RCA/CAPA draft for{" "} + The manufacturing data service did not return an RCA/CAPA draft for{" "} {result.detectionId}. Open a current runtime detection and use its RCA/CAPA draft link. @@ -57,18 +55,18 @@ export default async function RcaCapaDraftPage({ {!result.ok && !result.notFound ? : null} {result.ok && !result.draft ? ( ) : null} {result.ok && result.draft ? (
- This RCA/CAPA draft is generated from runtime detection, evidence, - and recommendation state. It is advisory decision support for human - review only; it is not a validated production record and it does not - submit anything to QMS or MES. + This draft is generated from case evidence and recommendation + state. It is advisory content for human review and is not a + validated production record until reviewed, approved, and submitted + through the appropriate quality workflow.
@@ -95,13 +93,13 @@ export default async function RcaCapaDraftPage({
System submission
- +
Source
- +
@@ -142,7 +140,7 @@ export default async function RcaCapaDraftPage({ className="secondary-action" href={`/detections/${result.draft.detection_id}`} > - Open detection + Review case
@@ -161,11 +159,6 @@ export default async function RcaCapaDraftPage({
) : null} -
-

- Configured API target: {getApiBaseUrl()} -

-
); } @@ -206,7 +199,7 @@ function formatDraftForCopy(draft: RcaCapaDraft): string { "", `Detection ID: ${draft.detection_id}`, "Human review required: yes", - "Demo-generated draft; not submitted to QMS/MES.", + "Investigation draft; not submitted to QMS/MES.", "", "Problem statement", draft.problem_statement, diff --git a/apps/web/app/recommendations/page.tsx b/apps/web/app/recommendations/page.tsx index 1e731d7..ebe5b82 100644 --- a/apps/web/app/recommendations/page.tsx +++ b/apps/web/app/recommendations/page.tsx @@ -4,7 +4,6 @@ import { ApiErrorPanel, EmptyState } from "../components/demo-state"; import { type Recommendation, formatApiError, - getApiBaseUrl, workbenchApi, } from "../../lib/api-client"; import { RecommendationReviewPanel } from "./recommendation-review-panel"; @@ -25,20 +24,19 @@ export default async function RecommendationsPage({ return (
- External-source governed recommendation + Recommendation pending review

Recommendation review

- Review the advisory Process Sentinel recommendation and record a human - decision. This recommendation is advisory and human-reviewed; no - industrial writeback is performed. + Review the advisory recommendation, supporting evidence, and + operational impact before recording a disposition.

{!result.ok ? : null} {result.ok && result.recommendations.length === 0 ? ( ) : null} @@ -53,9 +51,9 @@ export default async function RecommendationsPage({
Recommendations are advisory decision support. A named human reviewer - must approve, reject, or defer before any high-impact action is - considered. The Workbench does not perform industrial writeback, - product disposition, QMS/MES updates, or production CAPA creation. + must approve, reject, or defer the recommendation before any + external execution, system submission, or quality action is + initiated.
@@ -70,7 +68,7 @@ export default async function RecommendationsPage({ className="secondary-action" href={`/detections/${result.selected.detection_id}`} > - Open detection + Review case
@@ -92,11 +90,6 @@ export default async function RecommendationsPage({
) : null} -
-

- Configured API target: {getApiBaseUrl()} -

-
); } diff --git a/apps/web/app/recommendations/recommendation-review-panel.tsx b/apps/web/app/recommendations/recommendation-review-panel.tsx index 0535020..00a3c5a 100644 --- a/apps/web/app/recommendations/recommendation-review-panel.tsx +++ b/apps/web/app/recommendations/recommendation-review-panel.tsx @@ -104,11 +104,11 @@ export function RecommendationReviewPanel({ onSubmit={handleSubmit} >

- Enter a reviewer and decision reason before approving, rejecting, or - deferring this external-source recommendation. + Enter a reviewer name and decision reason before recording a + disposition.

- + submitDecision("approve")} type="button" > - {submittingAction === "approve" ? "Approving..." : "Approve"} + {submittingAction === "approve" ? "Approving..." : "Approve recommendation"}
) : null} diff --git a/apps/web/e2e/operations-workbench-demo.spec.ts b/apps/web/e2e/operations-workbench-demo.spec.ts index 5fc2759..8fabf59 100644 --- a/apps/web/e2e/operations-workbench-demo.spec.ts +++ b/apps/web/e2e/operations-workbench-demo.spec.ts @@ -19,10 +19,22 @@ test("walks the external-source Operations Workbench demo path", async ({ page } await expect(page.getByText("Connections", { exact: true })).toBeVisible(); await expect(page.getByText("Protocol Diagnostics", { exact: true })).toBeVisible(); await expect(page.getByText("Tag/Source Browser", { exact: true })).toBeVisible(); - await expect(page.getByRole("region", { name: "Workbench status strip" })).toBeVisible(); - await expect(page.getByText("Read-only diagnostics")).toBeVisible(); - await expect(page.getByText("Writeback")).toBeVisible(); - await expect(page.getByText("Disabled", { exact: true })).toBeVisible(); + await expect(page.getByRole("region", { name: "Workbench status strip" })).toHaveCount(0); + await expect(page.getByText("Data freshness")).toHaveCount(0); + await expect(page.getByRole("link", { name: "Configure Connections" }).first()).toBeVisible(); + const contextQuestion = page.getByRole("textbox", { + name: "Ask about current factory context", + }); + await expect(contextQuestion).toBeVisible(); + await page + .getByRole("textbox", { name: "Ask about current factory context" }) + .fill("What is the most important finding right now?"); + await page.getByRole("button", { name: "Ask" }).click(); + await expect(page.getByText("supported", { exact: true })).toBeVisible(); + await expect(page.getByText("highest-priority finding")).toBeVisible(); + await page.locator(`a[href="/detections/${detectionId}"]`).first().click(); + await expect(page.getByRole("heading", { name: "Detection detail" })).toBeVisible(); + await page.locator('a[href="/"]').first().click(); await page.locator('a[href="/connections"]').first().click(); const duplicateSourceProfiles = await createDuplicateSourceConnectionProfiles(page); await page.locator('a[href="/protocol-diagnostics"]').first().click(); @@ -84,20 +96,23 @@ test("walks the external-source Operations Workbench demo path", async ({ page } .click(); await page.locator('a[href="/"]').first().click(); await expect(page.getByText("Synthetic local scenario; not real plant data.")).toHaveCount(0); - await expect(page.getByRole("region", { name: "Local API connection state" })).toBeVisible(); - await expect(page.getByText("API target").first()).toBeVisible(); - await expect(page.getByText("Health", { exact: true }).first()).toBeVisible(); - await expect(page.getByText("external_demo_factory")).toBeVisible(); - await expect(page.getByRole("heading", { name: "Greenville Demo Site" })).toBeVisible(); - await expect(page.getByText("Active detections")).toBeVisible(); - await expect(page.getByText("Pending recommendations")).toBeVisible(); + await expect(page.getByText("API target").first()).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Greenville Manufacturing Site" })).toBeVisible(); + await expect(page.getByText("Active Cases")).toBeVisible(); + await expect(page.getByText("Pending Reviews")).toBeVisible(); + await expect(page.getByText("Factory Context", { exact: true })).toBeVisible(); + await expect(page.getByRole("img", { name: "Factory context graph" })).toBeVisible(); + await expect(page.getByText("Operational Context")).toBeVisible(); + await expect(page.getByText("Priority Case", { exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "Review case" })).toBeVisible(); + await expect(page.getByText("Integration & Governance")).toBeVisible(); await expect(page.getByText("Severity", { exact: true })).toBeVisible(); await expect(page.getByText("medium", { exact: true })).toBeVisible(); await page.locator('a[href="/detections"]').first().click(); await expect(page.getByRole("heading", { name: "Detections" })).toBeVisible(); await expect( - page.getByText("Process Sentinel detections from the current external-source event store"), + page.getByText("Process Sentinel cases generated from monitored process signals"), ).toBeVisible(); await page.locator(`a[href="/detections/${detectionId}"]`).click(); @@ -142,7 +157,7 @@ test("walks the external-source Operations Workbench demo path", async ({ page } await expect(page.getByRole("heading", { name: "Detection detail" })).toBeVisible(); await expect(page.getByRole("status")).toContainText("Detection not found"); await expect(page.getByRole("status")).toContainText("Next step:"); - await expect(page.getByRole("status")).toContainText("FIP Docker Compose stack"); + await expect(page.getByRole("status")).toContainText("service health"); }); async function createDuplicateSourceConnectionProfiles(page: Page) { @@ -206,10 +221,10 @@ async function recordDecision( expectedDecision: "approved" | "rejected" | "deferred", ) { await expect(page.getByRole("button", { name: action })).toBeDisabled(); - await page.getByLabel("Reviewer name").fill(`quality_engineer_${expectedDecision}`); + await page.getByLabel("Reviewer Name").fill(`quality_engineer_${expectedDecision}`); await page .getByLabel("Decision reason") - .fill(`Playwright smoke test recorded a ${expectedDecision} demo decision.`); + .fill(`Playwright smoke test recorded a ${expectedDecision} disposition.`); await expect(page.getByRole("button", { name: action })).toBeEnabled(); await expect(page.locator("#decision-submit-status")).toContainText( "Decision actions are available.", @@ -218,7 +233,7 @@ async function recordDecision( await page.keyboard.press("Enter"); const feedback = page.locator(".decision-result"); - await expect(feedback).toContainText(`Demo audit feedback: ${expectedDecision}`); + await expect(feedback).toContainText(`Disposition recorded: ${expectedDecision}`); await expect(feedback).toContainText(`Reviewer: quality_engineer_${expectedDecision}`); await expect(feedback).toContainText(`Updated status: ${expectedDecision}`); await expect(feedback).toContainText("not a validated production audit record"); diff --git a/apps/web/lib/api-client.ts b/apps/web/lib/api-client.ts index 760e482..b8927b6 100644 --- a/apps/web/lib/api-client.ts +++ b/apps/web/lib/api-client.ts @@ -1,4 +1,4 @@ -import { apiBaseUrl } from "./api-config"; +import { apiBaseUrl, getRequestApiBaseUrl } from "./api-config"; export type HealthResponse = { status: string; @@ -222,6 +222,25 @@ export type RecommendationDecisionRequest = { reason: string; }; +export type ContextQuestionRequest = { + question: string; +}; + +export type ContextQuestionSource = { + type: string; + id: string; + label: string; + href: string; +}; + +export type ContextQuestionResponse = { + question: string; + answer: string; + confidence: "supported" | "partial" | "unsupported"; + sources: ContextQuestionSource[]; + suggested_questions: string[]; +}; + export type ApprovalDecision = { approval_id: string; recommendation_id: string; @@ -300,6 +319,11 @@ export const workbenchApi = { listEquipment: () => requestJson("/equipment"), listProcessSignals: () => requestJson("/process-signals"), listBatches: () => requestJson("/batches"), + askContextQuestion: (request: ContextQuestionRequest) => + requestJson("/context/questions", { + body: JSON.stringify(request), + method: "POST", + }), listDetections: () => requestJson("/sentinel/detections"), getDetection: (detectionId: string) => requestJson(`/sentinel/detections/${encodeURIComponent(detectionId)}`), @@ -361,7 +385,7 @@ function postDecision( } async function requestJson(path: string, init: RequestInit = {}): Promise { - const response = await fetch(`${apiBaseUrl}${path}`, { + const response = await fetch(`${getRequestApiBaseUrl()}${path}`, { ...init, cache: "no-store", headers: { diff --git a/apps/web/lib/api-config.ts b/apps/web/lib/api-config.ts index 857a090..7c7d737 100644 --- a/apps/web/lib/api-config.ts +++ b/apps/web/lib/api-config.ts @@ -1,2 +1,16 @@ +const fallbackApiBaseUrl = "http://127.0.0.1:8000"; + +function cleanBaseUrl(value: string | undefined): string | undefined { + return value?.replace(/\/$/, ""); +} + export const apiBaseUrl = - process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "") ?? "http://127.0.0.1:8000"; + cleanBaseUrl(process.env.NEXT_PUBLIC_API_BASE_URL) ?? fallbackApiBaseUrl; + +export function getRequestApiBaseUrl(): string { + if (typeof window !== "undefined") { + return apiBaseUrl; + } + + return cleanBaseUrl(process.env.FIP_API_URL) ?? apiBaseUrl; +} diff --git a/apps/web/lib/product-copy.ts b/apps/web/lib/product-copy.ts new file mode 100644 index 0000000..4e4c5fc --- /dev/null +++ b/apps/web/lib/product-copy.ts @@ -0,0 +1,12 @@ +export const productCopy = { + dataHealthLabel: "Connected", + dataSourceLabel: "Manufacturing Data Service", + environmentLabel: "Validation", + integrationEndpointLabel: "Integration endpoint", + integrationStatusLabel: "Connected", + siteLocationLabel: "Greenville", + siteName: "Greenville Manufacturing Site", + siteSubtitle: "Process quality monitoring and recommendation review workspace.", + writebackPolicyLabel: "Human approval required", + writebackPolicyDetail: "Read-only; external submission not configured", +} as const; diff --git a/apps/web/tests/api-client.test.mjs b/apps/web/tests/api-client.test.mjs index 16e2826..b5ecfff 100644 --- a/apps/web/tests/api-client.test.mjs +++ b/apps/web/tests/api-client.test.mjs @@ -19,6 +19,7 @@ const requiredEndpoints = [ "/equipment", "/process-signals", "/batches", + "/context/questions", "/sentinel/detections", "/sentinel/detections/${encodeURIComponent(detectionId)}", "/sentinel/detections/${encodeURIComponent(detectionId)}/evidence", @@ -50,6 +51,10 @@ test("typed workbench API client covers demo endpoints", () => { assert.match(client, /listEquipment/); assert.match(client, /listProcessSignals/); assert.match(client, /listBatches/); + assert.match(client, /askContextQuestion/); + assert.match(client, /export type ContextQuestionResponse/); + assert.match(client, /confidence: "supported" \| "partial" \| "unsupported"/); + assert.match(client, /sources: ContextQuestionSource\[\]/); assert.match(client, /listDetections/); assert.match(client, /getDetection/); assert.match(client, /listDetectionEvidence/); diff --git a/apps/web/tests/app-shell.test.mjs b/apps/web/tests/app-shell.test.mjs index 90465dc..88bb91d 100644 --- a/apps/web/tests/app-shell.test.mjs +++ b/apps/web/tests/app-shell.test.mjs @@ -9,6 +9,8 @@ const root = fileURLToPath(new URL("..", import.meta.url)); const requiredRoutes = [ "app/page.tsx", + "app/context-question-panel.tsx", + "app/process-sentinel/page.tsx", "app/connections/page.tsx", "app/connections/connections-workspace.tsx", "app/protocol-diagnostics/page.tsx", @@ -27,33 +29,43 @@ test("workbench placeholder routes exist", async () => { await Promise.all(requiredRoutes.map((route) => access(join(root, route)))); }); -test("navigation includes the required demo routes", () => { +test("navigation includes the required workbench routes", () => { const layout = readFileSync(join(root, "app/layout.tsx"), "utf8"); + const navigation = readFileSync(join(root, "app/components/operator-navigation.tsx"), "utf8"); + const shell = `${layout}\n${navigation}`; assert.match(layout, /Skip to main content/); assert.match(layout, /id="main-content"/); assert.match(layout, /operator-shell/); assert.match(layout, /operator-sidebar/); assert.match(layout, /Workbench navigation/); - assert.match(layout, /Operator Console/); - assert.match(layout, /Sentinel workflows/); - assert.match(layout, /Overview/); - assert.match(layout, /Detections/); - assert.match(layout, /Recommendations/); - assert.match(layout, /RCA\/CAPA Draft/); - assert.match(layout, /Protocol operations/); - assert.match(layout, /Connections/); - assert.match(layout, /href: "\/connections"/); - assert.match(layout, /Protocol Diagnostics/); - assert.match(layout, /href: "\/protocol-diagnostics"/); - assert.match(layout, /Tag\/Source Browser/); - assert.match(layout, /href: "\/tag-source-browser"/); - assert.match(layout, /Workbench status strip/); - assert.doesNotMatch(layout, /
{ assert.doesNotMatch(workspace, /connections-actions/); }); -test("operator shell uses the Demo Factory console palette", () => { +test("operator shell uses the production operations console palette", () => { const styles = readFileSync(join(root, "app/globals.css"), "utf8"); - assert.match(styles, /--background: #e8eef3/); + assert.match(styles, /--background: #eef2f6/); assert.match(styles, /--surface: #ffffff/); assert.match(styles, /--text: #16202a/); assert.match(styles, /--muted: #526579/); assert.match(styles, /--border: #d3dee7/); - assert.match(styles, /--nav: #101820/); - assert.match(styles, /--nav-2: #1d2b36/); + assert.match(styles, /--nav: #07111b/); + assert.match(styles, /--nav-2: #101f2b/); assert.match(styles, /--accent: #0f766e/); assert.match(styles, /--warning: #b45309/); assert.match(styles, /--danger: #b91c1c/); assert.match(styles, /background: var\(--nav\)/); assert.match(styles, /background: var\(--nav-2\)/); + assert.match(styles, /operator-topbar/); + assert.match(styles, /sentinel-workbench-grid/); }); -test("overview page contains manufacturer demo dashboard content", () => { +test("overview page contains production operations dashboard content", () => { const overview = readFileSync(join(root, "app/page.tsx"), "utf8"); + const contextQuestion = readFileSync(join(root, "app/context-question-panel.tsx"), "utf8"); const demoState = readFileSync(join(root, "app/components/demo-state.tsx"), "utf8"); - - assert.match(overview, /Current demo context/); - assert.match(overview, /Active detections/); - assert.match(overview, /Pending recommendations/); - assert.match(overview, /Most important detection/); - assert.match(overview, /Open detection/); + const productCopy = readFileSync(join(root, "lib/product-copy.ts"), "utf8"); + + assert.match(overview, /ContextQuestionPanel/); + assert.match(contextQuestion, /Ask about current factory context/); + assert.match(contextQuestion, /workbenchApi\.askContextQuestion/); + assert.match(contextQuestion, /Context answer sources/); + assert.match(contextQuestion, /suggested_questions/); + assert.match(contextQuestion, /supported/); + assert.match(contextQuestion, /partial/); + assert.match(contextQuestion, /unsupported/); + assert.match(productCopy, /Greenville Manufacturing Site/); + assert.match(productCopy, /Process quality monitoring and recommendation review workspace/); + assert.match(overview, /Current operational state/); + assert.match(overview, /Active Cases/); + assert.match(overview, /Pending Reviews/); + assert.match(overview, /Factory Context/); + assert.match(overview, /FactoryContextGraph/); + assert.match(overview, /Connected operational graph/); + assert.match(overview, /Operational Context/); + assert.match(overview, /Priority case/); + assert.match(overview, /Review case/); + assert.match(overview, /Integration & Governance/); + assert.match(overview, /listProcessSignals/); assert.match(overview, /selectImportantDetection/); - assert.match(overview, /ApiConnectionBanner/); - assert.match(overview, /API base URL/); - assert.match(overview, /API health/); - assert.match(demoState, /Local API connected/); - assert.match(demoState, /API target/); - assert.match(demoState, /External source expected/); - assert.match(demoState, /Read-only runtime expected/); + assert.doesNotMatch(overview, /hero/); + assert.doesNotMatch(overview, /overview-kpi-strip/); + assert.doesNotMatch(overview, /ApiConnectionBanner/); + assert.doesNotMatch(overview, /API base URL/); + assert.match(demoState, /Integration details/); + assert.match(demoState, /Integration endpoint/); + assert.match(demoState, /productCopy\.dataSourceLabel/); + assert.match(demoState, /validation data source/); +}); + +test("process sentinel has an operations workbench route separate from overview", () => { + const route = readFileSync(join(root, "app/process-sentinel/page.tsx"), "utf8"); + const readme = readFileSync(join(root, "README.md"), "utf8"); + const ia = readFileSync( + join(root, "../../docs/demo/OPERATIONS_WORKBENCH_INFORMATION_ARCHITECTURE.md"), + "utf8", + ); + + assert.match(route, /Process Sentinel/); + assert.match(route, /Detection Queue/); + assert.match(route, /Selected Finding/); + assert.match(route, /Evidence Timeline/); + assert.match(route, /Governed Recommendation/); + assert.match(route, /Configure Connections/); + assert.match(route, /listDetectionEvidence/); + assert.match(route, /listRecommendations/); + assert.match(route, /listEquipment/); + assert.match(readme, /\/process-sentinel/); + assert.match(ia, /\/process-sentinel/); + assert.match(ia, /Overview is global factory context/); }); test("detections pages contain list and detail content", () => { @@ -271,10 +326,7 @@ test("detections pages contain list and detail content", () => { const demoState = readFileSync(join(root, "app/components/demo-state.tsx"), "utf8"); const styles = readFileSync(join(root, "app/globals.css"), "utf8"); - assert.match( - list, - /Process Sentinel detections from the current external-source event\s+store/, - ); + assert.match(list, /Process Sentinel cases generated from monitored process signals/); assert.match(list, /detection.summary/); assert.match(list, /detection.severity/); assert.match(list, /detection.confidence/); @@ -285,7 +337,8 @@ test("detections pages contain list and detail content", () => { assert.match(list, /formatTimeWindow/); assert.match(list, /related_work_order_id/); assert.match(list, /related_asset_ids/); - assert.match(list, /Open detection/); + assert.match(list, /Evidence count/); + assert.match(list, /Review case/); assert.match(detail, /Detection detail/); assert.match(detail, /getDetection/); assert.match(detail, /Detection not found/); @@ -318,7 +371,7 @@ test("detections pages contain list and detail content", () => { assert.match(detail, /RCA\/CAPA draft/); assert.match(detail, /rca-capa-draft\?detection_id=/); assert.doesNotMatch(detail, new RegExp("simulator-" + "backed", "i")); - assert.match(detail, /External-source evidence/); + assert.match(detail, /Linked evidence/); assert.match(demoState, /function StatusBadge/); assert.match(styles, /status-badge/); assert.match(styles, /status-badge-danger/); @@ -340,7 +393,7 @@ test("recommendation page contains governed review panel", () => { assert.match(page, /RecommendationReviewPanel/); assert.match(page, /Recommendations are advisory decision support/); assert.match(page, /rca-capa-draft\?detection_id=/); - assert.match(panel, /Reviewer name/); + assert.match(panel, /Reviewer Name/); assert.match(panel, /Decision reason/); assert.match(panel, /Approve/); assert.match(panel, /Reject/); @@ -348,7 +401,7 @@ test("recommendation page contains governed review panel", () => { assert.match(panel, /approveRecommendation/); assert.match(panel, /rejectRecommendation/); assert.match(panel, /deferRecommendation/); - assert.match(panel, /Demo audit feedback/); + assert.match(panel, /Disposition recorded/); assert.match(panel, /Recommendation ID/); assert.match(panel, /decisionResult\.recommendation_id/); assert.match(panel, /Reviewer:/); @@ -380,36 +433,40 @@ test("RCA CAPA page contains selected detection draft preview", () => { assert.match(page, /Evidence summary/); assert.match(page, /Recommended containment/); assert.match(page, /CAPA placeholder/); - assert.match(page, /human-review/); - assert.match(page, /not automatically submitted to QMS/); + assert.match(page, /Human review/); + assert.match(page, /System submission/); + assert.match(page, /Not submitted/); assert.match(page, /DraftCopyButton/); assert.match(page, /formatDraftForCopy/); assert.match(page, /StatusBadge/); assert.match(copyButton, /navigator\.clipboard\.writeText/); - assert.match(copyButton, /Copy draft text/); + assert.match(copyButton, /Copy draft/); assert.match(styles, /rca-draft-card/); assert.match(styles, /draft-section/); assert.match(styles, /draft-copy/); }); -test("app shell documents configurable API base URL", () => { +test("app shell keeps integration endpoint details in diagnostics copy", () => { const config = readFileSync(join(root, "lib/api-config.ts"), "utf8"); const client = readFileSync(join(root, "lib/api-client.ts"), "utf8"); const demoState = readFileSync(join(root, "app/components/demo-state.tsx"), "utf8"); const readme = readFileSync(join(root, "README.md"), "utf8"); assert.match(config, /NEXT_PUBLIC_API_BASE_URL/); + assert.match(config, /FIP_API_URL/); + assert.match(config, /getRequestApiBaseUrl/); + assert.match(config, /typeof window !== "undefined"/); assert.match(client, /apiBaseUrl/); + assert.match(client, /getRequestApiBaseUrl\(\)/); assert.match(client, /local FIP API/); assert.match(config, /http:\/\/127\.0\.0\.1:8000/); - assert.match(demoState, /Start Demo-Factory/); - assert.match(demoState, /FIP Docker Compose stack/); - assert.match(demoState, /NEXT_PUBLIC_API_BASE_URL/); + assert.match(demoState, /Integration details/); + assert.match(demoState, /Integration endpoint/); assert.doesNotMatch(demoState, /Synthetic local scenario; not real plant data/); assert.match(readme, /NEXT_PUBLIC_API_BASE_URL/); }); -test("workbench state panels distinguish local demo data gaps from API failures", () => { +test("workbench state panels distinguish operational data gaps from API failures", () => { const demoState = readFileSync(join(root, "app/components/demo-state.tsx"), "utf8"); const overview = readFileSync(join(root, "app/page.tsx"), "utf8"); const detections = readFileSync(join(root, "app/detections/page.tsx"), "utf8"); @@ -423,23 +480,23 @@ test("workbench state panels distinguish local demo data gaps from API failures" assert.match(demoState, /aria-busy="true"/); assert.match(demoState, /MissingDataPanel/); assert.match(demoState, /loading-panel/); - assert.match(overview, /Start Demo-Factory/); - assert.match(detections, /No Process Sentinel detections are available for the current external-source event store yet/); - assert.match(detections, /curl http:\/\/localhost:8000\/health/); + assert.match(overview, /current operational state/); + assert.match(detections, /No Process Sentinel cases are available for the current operational state yet/); + assert.match(detections, /Verify the validation data source and service health/); assert.match(detail, /Detection not found/); assert.match(detail, /No evidence available/); - assert.match(detail, /FIP Docker Compose stack/); + assert.match(detail, /manufacturing data service/); assert.match(recommendations, /No recommendations returned/); assert.match(recommendations, /No linked recommendation found/); - assert.match(recommendations, /current external-source event store yet/); + assert.match(recommendations, /current operational state yet/); assert.match(draft, /Draft not found/); assert.match(draft, /No detection available for draft preview/); - assert.match(draft, /Start Demo-Factory/); + assert.match(draft, /manufacturing data service/); assert.match(styles, /missing-data-panel/); assert.match(styles, /min-height: 260px/); }); -test("workbench runtime copy points to external sources and Docker recovery", () => { +test("workbench runtime copy points to operational sources and integration recovery", () => { const files = [ "app/layout.tsx", "app/page.tsx", @@ -453,10 +510,11 @@ test("workbench runtime copy points to external sources and Docker recovery", () ]; const combined = files.map((file) => readFileSync(join(root, file), "utf8")).join("\n"); - assert.match(combined, /Start Demo-Factory/); - assert.match(combined, /FIP Docker Compose stack/); - assert.match(combined, /The Workbench could not reach the local FIP API/); - assert.match(combined, /advisory and human-reviewed/); + assert.match(combined, /manufacturing data service/); + assert.match(combined, /validation data source/); + assert.match(combined, /The Workbench could not reach the configured manufacturing data service/); + assert.match(combined, /advisory decision support/); + assert.match(combined, /human-reviewed RCA\/CAPA draft/); assert.doesNotMatch(combined, new RegExp("simulator-" + "backed", "i")); assert.doesNotMatch(combined, new RegExp("rerun make" + " demo", "i")); assert.doesNotMatch(combined, new RegExp("make " + "demo-data", "i")); @@ -465,6 +523,7 @@ test("workbench runtime copy points to external sources and Docker recovery", () test("accessibility baseline covers landmarks, focus, forms, badges, and timeline order", () => { const layout = readFileSync(join(root, "app/layout.tsx"), "utf8"); + const navigation = readFileSync(join(root, "app/components/operator-navigation.tsx"), "utf8"); const demoState = readFileSync(join(root, "app/components/demo-state.tsx"), "utf8"); const detail = readFileSync(join(root, "app/detections/[detectionId]/page.tsx"), "utf8"); const panel = readFileSync( @@ -475,9 +534,9 @@ test("accessibility baseline covers landmarks, focus, forms, badges, and timelin assert.match(layout, /className="skip-link"/); assert.match(layout, /
ContextQuestionRequest: + stripped = self.question.strip() + if not stripped: + msg = "question must not be empty" + raise ValueError(msg) + self.question = stripped + return self + + +class ContextQuestionSource(BaseModel): + type: str = Field(min_length=1) + id: str = Field(min_length=1) + label: str = Field(min_length=1) + href: str = Field(min_length=1) + + +class ContextQuestionResponse(BaseModel): + question: str + answer: str + confidence: ContextQuestionConfidence + sources: list[ContextQuestionSource] + suggested_questions: list[str] = Field(default_factory=list) + + +class RuntimeContext(BaseModel): + health: dict[str, str] + connection_profiles: list[ProtocolConnectionProfile] + domain: DomainData + detections: list[Detection] + evidence: list[EvidenceItem] + recommendations: list[Recommendation] + + +def answer_context_question( + request: ContextQuestionRequest, + context: RuntimeContext, +) -> ContextQuestionResponse: + question_key = request.question.lower() + + if _asks_about_health(question_key): + return _answer_health(request.question, context) + if _asks_why_detection_was_flagged(question_key): + return _answer_detection_evidence(request.question, context) + if _asks_about_recommendations(question_key): + return _answer_recommendations(request.question, context) + if _asks_about_detections(question_key): + return _answer_detections(request.question, context) + if _asks_about_current_context(question_key): + return _answer_current_context(request.question, context, question_key) + return _unsupported(request.question) + + +def _answer_current_context( + question: str, + context: RuntimeContext, + question_key: str, +) -> ContextQuestionResponse: + detection = _select_primary_detection(context.detections) + batch = _select_current_batch(context.domain) + asset = _select_current_asset(context.domain, detection) + area = _select_current_area(context.domain, batch, asset) + site = _select_current_site(context.domain, area, batch) + work_order_id = detection.related_work_order_id if detection is not None else None + + answer = ( + f"Current context is {site.name if site else 'an unknown site'}, " + f"{area.name if area else 'an unknown area'}, " + f"{asset.name if asset else 'an unlinked asset'}, " + f"batch {batch.batch_id if batch else 'unavailable'}, " + f"work order {work_order_id or 'unavailable'}, and product " + f"{batch.product_name if batch else 'unavailable'}." + ) + confidence: ContextQuestionConfidence = "supported" + if any(term in question_key for term in ("operator", "supervisor", "shift")): + answer += ( + " Operator, supervisor, and shift assignment are not present in the " + "current browser-safe context." + ) + confidence = "partial" + + sources: list[ContextQuestionSource] = [] + if site is not None: + sources.append( + ContextQuestionSource( + type="site", + id=site.site_id, + label=site.name, + href="/", + ) + ) + if area is not None: + sources.append( + ContextQuestionSource( + type="area", + id=area.area_id, + label=area.name, + href="/", + ) + ) + if asset is not None: + sources.append( + ContextQuestionSource( + type="asset", + id=asset.equipment_id, + label=asset.name, + href="/tag-source-browser", + ) + ) + if batch is not None: + sources.append( + ContextQuestionSource( + type="batch", + id=batch.batch_id, + label=batch.batch_id, + href="/", + ) + ) + if work_order_id is not None and detection is not None: + sources.append( + ContextQuestionSource( + type="work_order", + id=work_order_id, + label=work_order_id, + href=f"/detections/{detection.detection_id}", + ) + ) + return ContextQuestionResponse( + question=question, + answer=answer, + confidence=confidence, + sources=sources, + ) + + +def _answer_detections(question: str, context: RuntimeContext) -> ContextQuestionResponse: + active_detections = _active_detections(context.detections) + primary = _select_primary_detection(context.detections) + if primary is None: + return ContextQuestionResponse( + question=question, + answer="There are no active Process Sentinel detections in the current runtime state.", + confidence="supported", + sources=[ + ContextQuestionSource( + type="detection_list", + id="sentinel-detections", + label="Process Sentinel detections", + href="/detections", + ) + ], + ) + + return ContextQuestionResponse( + question=question, + answer=( + f"There are {len(active_detections)} active Process Sentinel detections. " + f"The highest-priority finding is {primary.summary} " + f"({primary.detection_id}) with {primary.severity} severity, " + f"{primary.status} status, and {round(primary.confidence * 100)}% confidence." + ), + confidence="supported", + sources=[_detection_source(primary)], + ) + + +def _answer_detection_evidence(question: str, context: RuntimeContext) -> ContextQuestionResponse: + primary = _select_primary_detection(context.detections) + if primary is None: + return ContextQuestionResponse( + question=question, + answer=( + "No primary detection is available, so there is no evidence " + "summary to explain yet." + ), + confidence="partial", + sources=[], + ) + evidence_items = [ + item for item in context.evidence if item.detection_id == primary.detection_id + ] + if not evidence_items: + return ContextQuestionResponse( + question=question, + answer=( + f"{primary.summary} is the primary detection, but no evidence items are " + "available in the current Sentinel state." + ), + confidence="partial", + sources=[_detection_source(primary)], + ) + + evidence_summary = "; ".join(item.description for item in evidence_items[:3]) + return ContextQuestionResponse( + question=question, + answer=f"{primary.summary} was flagged because {evidence_summary}", + confidence="supported", + sources=[ + _detection_source(primary), + *[ + ContextQuestionSource( + type="evidence", + id=item.evidence_id, + label=item.title, + href=f"/detections/{primary.detection_id}", + ) + for item in evidence_items[:3] + ], + ], + ) + + +def _answer_recommendations(question: str, context: RuntimeContext) -> ContextQuestionResponse: + pending = [ + recommendation + for recommendation in context.recommendations + if recommendation.status in PENDING_RECOMMENDATION_STATUSES + ] + if not pending: + return ContextQuestionResponse( + question=question, + answer="There are no pending governed recommendations in the current runtime state.", + confidence="supported", + sources=[ + ContextQuestionSource( + type="recommendation_list", + id="recommendations", + label="Recommendation review", + href="/recommendations", + ) + ], + ) + + statuses = ", ".join( + f"{recommendation.recommendation_id}: {recommendation.status}" + for recommendation in pending[:3] + ) + return ContextQuestionResponse( + question=question, + answer=( + f"There are {len(pending)} pending governed recommendations. Current " + f"recommendation status: {statuses}." + ), + confidence="supported", + sources=[ + ContextQuestionSource( + type="recommendation", + id=recommendation.recommendation_id, + label=recommendation.recommended_action, + href=f"/recommendations?detection_id={recommendation.detection_id}", + ) + for recommendation in pending[:3] + ], + ) + + +def _answer_health(question: str, context: RuntimeContext) -> ContextQuestionResponse: + enabled_profiles = [ + profile for profile in context.connection_profiles if profile.enabled + ] + disabled_profiles = [ + profile for profile in context.connection_profiles if not profile.enabled + ] + health = context.health + return ContextQuestionResponse( + question=question, + answer=( + f"The API reports {health.get('status', 'unknown')} health, " + f"{health.get('source_mode', 'unknown')} source mode, " + f"{health.get('storage_backend', 'unknown')} storage, and " + f"{health.get('connector_mode', 'unknown')} connector mode. " + f"There are {len(enabled_profiles)} enabled and {len(disabled_profiles)} " + "disabled connection profiles visible to the browser." + ), + confidence="supported", + sources=[ + ContextQuestionSource( + type="health", + id="api-health", + label="API health", + href="/protocol-diagnostics", + ), + ContextQuestionSource( + type="connection_profiles", + id="connection-profiles", + label="Connection profiles", + href="/connections", + ), + ], + ) + + +def _unsupported(question: str) -> ContextQuestionResponse: + return ContextQuestionResponse( + question=question, + answer=( + "I can only answer deterministic questions about current factory context, " + "Process Sentinel detections, evidence, recommendations, and browser-safe " + "runtime health. Try one of the example questions below." + ), + confidence="unsupported", + sources=[], + suggested_questions=EXAMPLE_QUESTIONS, + ) + + +def _asks_about_current_context(question_key: str) -> bool: + return any( + term in question_key + for term in ( + "site", + "area", + "line", + "asset", + "batch", + "work order", + "product", + "context", + "operator", + "supervisor", + "shift", + ) + ) + + +def _asks_about_detections(question_key: str) -> bool: + return any( + term in question_key + for term in ("detection", "finding", "findings", "important", "priority", "count") + ) + + +def _asks_why_detection_was_flagged(question_key: str) -> bool: + return any(term in question_key for term in ("why", "flagged", "evidence")) + + +def _asks_about_recommendations(question_key: str) -> bool: + return any( + term in question_key + for term in ("recommendation", "recommendations", "pending", "status") + ) + + +def _asks_about_health(question_key: str) -> bool: + return any( + term in question_key + for term in ("health", "api", "source", "connector", "connection", "profile", "storage") + ) + + +def _active_detections(detections: list[Detection]) -> list[Detection]: + return [item for item in detections if item.status in ACTIVE_DETECTION_STATUSES] + + +def _select_primary_detection(detections: list[Detection]) -> Detection | None: + active_detections = _active_detections(detections) + if not active_detections: + return None + return sorted( + active_detections, + key=lambda item: (SEVERITY_RANK[item.severity], item.confidence), + reverse=True, + )[0] + + +def _select_current_batch(domain: DomainData): + return next( + (batch for batch in domain.batches if batch.status in {"running", "held"}), + domain.batches[0] if domain.batches else None, + ) + + +def _select_current_asset(domain: DomainData, detection: Detection | None): + related_asset_id = ( + detection.related_asset_ids[0] if detection and detection.related_asset_ids else None + ) + return next( + (asset for asset in domain.equipment if asset.equipment_id == related_asset_id), + domain.equipment[0] if domain.equipment else None, + ) + + +def _select_current_area(domain: DomainData, batch, asset): + return next( + ( + area + for area in domain.areas + if area.area_id in {getattr(asset, "area_id", None), getattr(batch, "area_id", None)} + ), + domain.areas[0] if domain.areas else None, + ) + + +def _select_current_site(domain: DomainData, area, batch): + return next( + ( + site + for site in domain.sites + if site.site_id in {getattr(area, "site_id", None), getattr(batch, "site_id", None)} + ), + domain.sites[0] if domain.sites else None, + ) + + +def _detection_source(detection: Detection) -> ContextQuestionSource: + return ContextQuestionSource( + type="detection", + id=detection.detection_id, + label=detection.summary, + href=f"/detections/{detection.detection_id}", + ) diff --git a/services/api/factory_api/domain.py b/services/api/factory_api/domain.py index 3557053..e7201cc 100644 --- a/services/api/factory_api/domain.py +++ b/services/api/factory_api/domain.py @@ -190,10 +190,10 @@ def build_demo_domain_data() -> DomainData: sites=[ Site( site_id="greenville_demo_site", - name="Greenville Demo Site", + name="Greenville Manufacturing Site", timezone="America/New_York", description=( - "External-source Greenville site context used for the manufacturer demo." + "Process quality monitoring and recommendation review workspace." ), ) ], @@ -244,7 +244,7 @@ def build_demo_domain_data() -> DomainData: batch_id="BATCH-DEMO-1007", site_id="greenville_demo_site", area_id="packaging_area", - product_name="OFI Demo Beverage", + product_name="Beverage Line Product", status="held", started_at=started_at, ended_at=None, diff --git a/services/api/factory_api/main.py b/services/api/factory_api/main.py index 00e80b7..af0a6d0 100644 --- a/services/api/factory_api/main.py +++ b/services/api/factory_api/main.py @@ -24,6 +24,11 @@ serialize_connection_profile_for_browser, ) from factory_api.connection_tests import test_connection_profile_data +from factory_api.context_questions import ( + ContextQuestionRequest, + RuntimeContext, + answer_context_question, +) from factory_api.domain import DomainData, build_demo_domain_data DEFAULT_CORS_ORIGINS = ( @@ -160,13 +165,18 @@ def connection_profile_store() -> ConnectionProfileStore: def domain() -> DomainData: return resolved_domain_data - @app.get("/health") - def health() -> dict: + def runtime_health() -> dict[str, str]: return { "status": "ok", "source_mode": resolved_source_mode, "storage_backend": resolved_storage_backend, "connector_mode": resolved_connector_mode, + } + + @app.get("/health") + def health() -> dict: + return { + **runtime_health(), "simulator_backed": False, "simulator_backed_migration_note": ( "Deprecated compatibility field. Use source_mode and connector_mode " @@ -177,6 +187,21 @@ def health() -> dict: "connection_profiles_store": str(resolved_connection_profiles_store), } + @app.post("/context/questions") + def answer_context_question_endpoint(request: ContextQuestionRequest) -> dict: + response = answer_context_question( + request, + RuntimeContext( + health=runtime_health(), + connection_profiles=connection_profile_store().list_profiles(), + domain=domain(), + detections=sentinel_store().list_detections(), + evidence=sentinel_store().list_evidence(), + recommendations=sentinel_store().list_recommendations(), + ), + ) + return response.model_dump(mode="json") + @app.get("/events") def list_events(event_type: str | None = None) -> list[dict]: events = event_store().list_events() diff --git a/services/api/tests/test_api.py b/services/api/tests/test_api.py index 60c1154..dd61e02 100644 --- a/services/api/tests/test_api.py +++ b/services/api/tests/test_api.py @@ -38,6 +38,156 @@ def test_health_endpoint(tmp_path: Path) -> None: assert "Deprecated compatibility field" in health["simulator_backed_migration_note"] +def test_context_question_endpoint_answers_supported_detection_question( + tmp_path: Path, +) -> None: + events_store_path, state_dir = seed_state(tmp_path) + client = TestClient( + create_app(events_store_path=events_store_path, sentinel_state_dir=state_dir) + ) + + response = client.post( + "/context/questions", + json={"question": "What is the most important finding right now?"}, + ) + + body = response.json() + assert response.status_code == 200 + assert body["question"] == "What is the most important finding right now?" + assert body["confidence"] == "supported" + assert "highest-priority finding" in body["answer"] + assert "det_fill_weight_gradual_drift" in body["answer"] + assert body["sources"] == [ + { + "type": "detection", + "id": "det_fill_weight_gradual_drift", + "label": "Fill weight is trending upward toward the upper quality limit.", + "href": "/detections/det_fill_weight_gradual_drift", + } + ] + + +def test_context_question_endpoint_uses_evidence_for_why_question( + tmp_path: Path, +) -> None: + events_store_path, state_dir = seed_state(tmp_path) + client = TestClient( + create_app(events_store_path=events_store_path, sentinel_state_dir=state_dir) + ) + + response = client.post( + "/context/questions", + json={"question": "Why was the primary detection flagged?"}, + ) + + body = response.json() + assert response.status_code == 200 + assert body["confidence"] == "supported" + assert "was flagged because" in body["answer"] + assert "Baseline average" in body["answer"] + assert [source["type"] for source in body["sources"]] == [ + "detection", + "evidence", + "evidence", + ] + assert body["sources"][1]["href"] == "/detections/det_fill_weight_gradual_drift" + + +def test_context_question_endpoint_returns_partial_context_when_field_missing( + tmp_path: Path, +) -> None: + events_store_path, state_dir = seed_state(tmp_path) + client = TestClient( + create_app(events_store_path=events_store_path, sentinel_state_dir=state_dir) + ) + + response = client.post( + "/context/questions", + json={"question": "What is the current batch and shift supervisor?"}, + ) + + body = response.json() + assert response.status_code == 200 + assert body["confidence"] == "partial" + assert "BATCH-DEMO-1007" in body["answer"] + assert "shift assignment" in body["answer"] + assert {source["type"] for source in body["sources"]} >= { + "site", + "area", + "asset", + "batch", + "work_order", + } + + +def test_context_question_endpoint_answers_recommendation_and_health_questions( + tmp_path: Path, +) -> None: + events_store_path, state_dir = seed_state(tmp_path) + client = TestClient( + create_app(events_store_path=events_store_path, sentinel_state_dir=state_dir) + ) + + recommendation_response = client.post( + "/context/questions", + json={"question": "How many recommendations are pending review?"}, + ) + health_response = client.post( + "/context/questions", + json={"question": "What is the API and source health?"}, + ) + + recommendation = recommendation_response.json() + health = health_response.json() + assert recommendation_response.status_code == 200 + assert recommendation["confidence"] == "supported" + assert "1 pending governed recommendations" in recommendation["answer"] + assert recommendation["sources"][0]["type"] == "recommendation" + assert recommendation["sources"][0]["href"] == ( + "/recommendations?detection_id=det_fill_weight_gradual_drift" + ) + assert health_response.status_code == 200 + assert health["confidence"] == "supported" + assert "external_demo_factory source mode" in health["answer"] + assert [source["href"] for source in health["sources"]] == [ + "/protocol-diagnostics", + "/connections", + ] + + +def test_context_question_endpoint_fails_safely_for_unsupported_question( + tmp_path: Path, +) -> None: + events_store_path, state_dir = seed_state(tmp_path) + client = TestClient( + create_app(events_store_path=events_store_path, sentinel_state_dir=state_dir) + ) + + response = client.post( + "/context/questions", + json={"question": "Write a CAPA and close the deviation automatically."}, + ) + + body = response.json() + assert response.status_code == 200 + assert body["confidence"] == "unsupported" + assert "deterministic questions" in body["answer"] + assert body["sources"] == [] + assert "What is the most important finding right now?" in body["suggested_questions"] + + +def test_context_question_endpoint_rejects_empty_question(tmp_path: Path) -> None: + events_store_path, state_dir = seed_state(tmp_path) + client = TestClient( + create_app(events_store_path=events_store_path, sentinel_state_dir=state_dir) + ) + + response = client.post("/context/questions", json={"question": " "}) + + assert response.status_code == 422 + assert response.json()["error"]["code"] == "request_validation_failed" + + def test_event_and_detection_query_endpoints(tmp_path: Path) -> None: events_store_path, state_dir = seed_state(tmp_path) client = TestClient( @@ -71,8 +221,7 @@ def test_demo_detection_detail_endpoint_exposes_expected_lookup_fields( ] assert detail_response.json()["detection_id"] == "det_fill_weight_gradual_drift" assert detail_response.json()["summary"] == ( - "Advisory: fill weight is trending upward, which may move the affected " - "work order toward the upper quality limit." + "Fill weight is trending upward toward the upper quality limit." ) assert "root cause" not in detail_response.json()["summary"].lower() assert "caused by" not in detail_response.json()["summary"].lower() @@ -169,7 +318,7 @@ def test_domain_model_query_endpoints(tmp_path: Path) -> None: investigation_response = client.get("/investigations/inv_fill_weight_drift_WO_DEMO_1007") assert site_response.status_code == 200 - assert site_response.json()["name"] == "Greenville Demo Site" + assert site_response.json()["name"] == "Greenville Manufacturing Site" assert signal_response.status_code == 200 assert signal_response.json()["equipment_id"] == "filler_f_201" assert quality_response.status_code == 200 @@ -234,7 +383,7 @@ def test_demo_recommendation_endpoints_expose_review_fields(tmp_path: Path) -> N assert recommendation["detection_id"] == "det_fill_weight_gradual_drift" assert recommendation["status"] == "needs_review" assert recommendation["recommended_action"] == ( - "Inspect filler calibration and increase quality checks for the affected demo work " + "Inspect filler calibration and increase quality checks for the affected work " "order." ) assert recommendation["rationale"] == ( diff --git a/services/api/tests/test_domain.py b/services/api/tests/test_domain.py index 0f5753e..6867112 100644 --- a/services/api/tests/test_domain.py +++ b/services/api/tests/test_domain.py @@ -17,7 +17,7 @@ def test_demo_domain_links_process_signals_to_quality_and_investigation() -> Non investigation = domain.get_investigation_detail("inv_fill_weight_drift_WO_DEMO_1007") assert site is not None - assert site.name == "Greenville Demo Site" + assert site.name == "Greenville Manufacturing Site" assert area is not None assert area.name == "Packaging Area" assert filler is not None diff --git a/services/process-sentinel/process_sentinel/rules.py b/services/process-sentinel/process_sentinel/rules.py index ccc848c..8443d3f 100644 --- a/services/process-sentinel/process_sentinel/rules.py +++ b/services/process-sentinel/process_sentinel/rules.py @@ -67,8 +67,7 @@ def _detect_gradual_fill_weight_drift( time_window_start=baseline[0].timestamp, time_window_end=recent[-1].timestamp, summary=( - "Advisory: fill weight is trending upward, which may move the affected " - "work order toward the upper quality limit." + "Fill weight is trending upward toward the upper quality limit." ), confidence=min(0.95, 0.55 + delta / 10), related_work_order_id=recent[-1].context.work_order_id, @@ -176,11 +175,11 @@ def _recommendation_for_detection( detection: Detection, evidence_items: Sequence[EvidenceItem] ) -> Recommendation: if detection.severity == "high": - action = "Inspect filler operation and hold affected demo work order for quality review." + action = "Inspect filler operation and hold the affected work order for quality review." rationale = "A process excursion can affect product quality and requires human review." else: action = ( - "Inspect filler calibration and increase quality checks for the affected demo work " + "Inspect filler calibration and increase quality checks for the affected work " "order." ) rationale = "The trend is moving toward the quality limit before confirmed broad failure." diff --git a/services/process-sentinel/tests/test_rules.py b/services/process-sentinel/tests/test_rules.py index 2ddd14f..62b3228 100644 --- a/services/process-sentinel/tests/test_rules.py +++ b/services/process-sentinel/tests/test_rules.py @@ -89,8 +89,7 @@ def test_fill_weight_drift_demo_supports_detection_recommendation_and_rca_draft( assert [item.detection_type for item in result.detections] == ["quality_drift"] assert detection.detection_id == "det_fill_weight_gradual_drift" assert detection.summary == ( - "Advisory: fill weight is trending upward, which may move the affected " - "work order toward the upper quality limit." + "Fill weight is trending upward toward the upper quality limit." ) assert "root cause" not in detection.summary.lower() assert "caused by" not in detection.summary.lower() diff --git a/services/simulator/tests/test_connection_management_epic_readiness.py b/services/simulator/tests/test_connection_management_epic_readiness.py index 76e1d30..96bbaf9 100644 --- a/services/simulator/tests/test_connection_management_epic_readiness.py +++ b/services/simulator/tests/test_connection_management_epic_readiness.py @@ -71,6 +71,8 @@ def test_issue_206_test_connection_results_are_read_only_structured_and_redacted def test_issue_206_operator_console_includes_protocol_and_sentinel_workflows() -> None: layout = _content("apps/web/app/layout.tsx") + navigation = _content("apps/web/app/components/operator-navigation.tsx") + shell = f"{layout}\n{navigation}" connections = _content("apps/web/app/connections/connections-workspace.tsx") diagnostics = _content( "apps/web/app/protocol-diagnostics/protocol-diagnostics-workspace.tsx" @@ -79,17 +81,25 @@ def test_issue_206_operator_console_includes_protocol_and_sentinel_workflows() - for expected in [ "operator-sidebar", - "Workbench status strip", + "Global operations toolbar", + "Configure Connections", + ]: + assert expected in layout + + for expected in [ "Sentinel workflows", "Protocol operations", 'href: "/connections"', 'href: "/protocol-diagnostics"', 'href: "/tag-source-browser"', - "Read-only diagnostics", - "Writeback", - "Disabled", + 'href: "/process-sentinel"', ]: - assert expected in layout + assert expected in shell + + assert "Workbench status strip" not in layout + assert "Data freshness" not in layout + assert "Historian" not in layout + assert "sidebar-config-link" not in layout assert "OPC-UA controls" in connections assert "MQTT controls" in connections diff --git a/services/simulator/tests/test_docker_compose_runtime_docs.py b/services/simulator/tests/test_docker_compose_runtime_docs.py index 5e25667..809e133 100644 --- a/services/simulator/tests/test_docker_compose_runtime_docs.py +++ b/services/simulator/tests/test_docker_compose_runtime_docs.py @@ -106,6 +106,8 @@ def test_docker_compose_runtime_defines_fip_services_without_simulator() -> None "${FIP_WEB_PORT:-3000}:3000", "FIP_PUBLIC_API_URL", "FIP_API_URL: http://api:8000", + "http://localhost:8000", + "http://api:8000", "FACTORY_CONNECTION_PROFILES_STORE", "DEMO_FACTORY_HOST", "DEMO_FACTORY_OPCUA_ENDPOINT", @@ -134,6 +136,9 @@ def test_docker_compose_runtime_defines_fip_services_without_simulator() -> None ]: assert service in runtime_doc assert "It does not start an in-repo simulator service." in runtime_doc + assert "server-rendered\nNext.js requests" in runtime_doc + assert "`web` container use `FIP_API_URL`" in runtime_doc + assert "`http://localhost:8000` points\nback at the `web` container" in runtime_doc def test_makefile_includes_compose_wrappers() -> None: