Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions GROUND_TRUTH.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **Single Source of Truth.** Dieses Dokument beschreibt, was *gilt* — Schema, Routen, Stack, Konventionen. Bei Widerspruch zwischen Code und diesem Dokument gewinnt zunächst dieses Dokument; danach wird eines von beiden korrigiert. Stand-Datum bei jeder Änderung aktualisieren.

**Stand:** 2026-06-16 · **Status:** F7 — MCP-Schnittstelle (FOREMAN als offener Knoten, **zweiter Differenzierungs-Pfeiler „Plattform statt App"**). Neue Schicht `src/foreman/mcp/`: ein **read-only** Model-Context-Protocol-Server (Anthropic SDK / FastMCP, Streamable HTTP), der die aggregierten Reasoner-Erkenntnisse als **11 maschinenlesbare Tools** an Drittsysteme (Simulation/ERP/Energiemanagement) reicht. Drei Invarianten, strukturell verankert: **(I) read-only** — keine Aktorik, kein Reasoner-/LLM-Trigger über MCP (MCP-eigene Read-Schicht `reads.py`, ausschließlich SELECT); **(II) AI-Act-Transparenz** an jedem KI-Output (Art. 50(2): `ai_generated`/`generated_by`/`requires_human_review`/`model_version` + bei Vorhersage/Empfehlung `validation_status`/`data_regime`/`validation_caveat`) — ein gemeinsamer Wrapper, dessen Validator einen unehrlichen Umschlag nicht zulässt; **(III) IP-Wording** — kein internes Vokabular in Tool-Namen/-Beschreibungen/-Schemata (Hidden-Term-Scan als Akzeptanzkriterium). Eigener `FOREMAN_MCP_`-Token (getrennt vom Plattform-JWT, Fail-Closed), PII nur pseudonymisiert/maskiert (Token nie aufgelöst), `foreman_mcp_*`-Metriken, eigenständige ASGI-App (eigener Port, eigene `/health`/`/metrics`). **Erfüllt zugleich AI-Act-Maßnahme §10.5(2) — Transparenz-Flag MCP: „gebaut".** Vertrag: **§17**.
**Stand:** 2026-06-25 · **Status:** F7 — MCP-Schnittstelle (FOREMAN als offener Knoten, **zweiter Differenzierungs-Pfeiler „Plattform statt App"**). Neue Schicht `src/foreman/mcp/`: ein **read-only** Model-Context-Protocol-Server (Anthropic SDK / FastMCP, Streamable HTTP), der die aggregierten Reasoner-Erkenntnisse als **11 maschinenlesbare Tools** an Drittsysteme (Simulation/ERP/Energiemanagement) reicht. Drei Invarianten, strukturell verankert: **(I) read-only** — keine Aktorik, kein Reasoner-/LLM-Trigger über MCP (MCP-eigene Read-Schicht `reads.py`, ausschließlich SELECT); **(II) AI-Act-Transparenz** an jedem KI-Output (Art. 50(2): `ai_generated`/`generated_by`/`requires_human_review`/`model_version` + bei Vorhersage/Empfehlung `validation_status`/`data_regime`/`validation_caveat`) — ein gemeinsamer Wrapper, dessen Validator einen unehrlichen Umschlag nicht zulässt; **(III) IP-Wording** — kein internes Vokabular in Tool-Namen/-Beschreibungen/-Schemata (Hidden-Term-Scan als Akzeptanzkriterium). Eigener `FOREMAN_MCP_`-Token (getrennt vom Plattform-JWT, Fail-Closed), PII nur pseudonymisiert/maskiert (Token nie aufgelöst), `foreman_mcp_*`-Metriken, eigenständige ASGI-App (eigener Port, eigene `/health`/`/metrics`). **Erfüllt zugleich AI-Act-Maßnahme §10.5(2) — Transparenz-Flag MCP: „gebaut".** Vertrag: **§17**.

*Vorgänger-Status F-REC — LLM-Werker-Empfehlung (Erklär-Layer über F-PRED, **zweiter Konsument des `LLMGateway`** nach F6): aus einer `FailurePrediction` + SHAP-Faktoren (`trusted=True`) + NEXUS-Recall (`trusted=False`, best-effort) eine deutsche Werker-Empfehlung über `gateway.complete(task=explanation)`. Zwei strukturell erzwungene Invarianten: (I) Zahlen autoritativ vom Modell — der numerische Post-Check **rejectet** (nicht: flaggt) jede unbelegte Zahl, keine Persistenz; (II) deterministischer Sim-Vorbehalt — `validation_caveat` aus `validation_caveat_for(...)`, nie aus dem LLM. Persistenz `failure_recommendations` (Migration `0007`, FK auf `failure_predictions`) + Dual-Write. Red-Team scharf über den Recall-Pfad ✅. Vertrag: §16.5.*

Expand Down Expand Up @@ -117,7 +117,7 @@ Eigenständiger Model-Context-Protocol-Server (Anthropic SDK / FastMCP, **Stream

In die Plattform-FastAPI-App integriert (nicht der MCP-Server). Vollständiger Vertrag: **§20**.

- `GET /api/v1/overview` — Flotten-Lagebild (Statusleiste/Cockpit): je Maschine komponierter FCSM-Status + offene Alarme nach Severity + jüngster offener Alarm, plus Status-Rollup. Auth-pflichtig; **scope-korrekt + autorisiert** wie das WS-`overview`-Thema — nur `manager`/`shift_lead` (sonst **403**), `shift_lead` auf seine Linien gefiltert.
- `GET /api/v1/overview` — Flotten-Lagebild (Statusleiste/Cockpit): je Maschine komponierter FCSM-Status + offene Alarme nach Severity + jüngster offener Alarm, plus Status-Rollup. Trägt zusätzlich den **scope-unabhängigen Eingangs-Stream-Status** `stream: {active, last_reading_at}` (Zwilling als Datenquelle, §22.2) — speist das globale „Live"-Badge **ehrlich** (kein Live-Etikett über statischer Historie). Auth-pflichtig; **scope-korrekt + autorisiert** wie das WS-`overview`-Thema — nur `manager`/`shift_lead` (sonst **403**), `shift_lead` auf seine Linien gefiltert.
- `GET /api/v1/machines/{machine_id}/trend?datapoint=<name>&hours=<1–168>` — aggregierter `readings_1m`-Trend eines Datenpunkts + statisches Normalband (`normal_min`/`normal_max`). Auth-pflichtig; **gleiche Maschinen-Scope-Autorisierung** wie das WS-`machine`-Thema (**403** außerhalb des Scopes). **404**, wenn der Datenpunkt an der Maschine nicht existiert.
- `WS /api/v1/ws?token=<jwt>` — **EIN** gemultiplexter WebSocket-Kanal mit Themen-Abos. Auth über Query-Token (die AuthMiddleware lässt WS-Scope durch → manuelle Auth, Close-Code 4401). Client-Nachrichten `{action: subscribe|unsubscribe, topic}`; jeder `subscribe` wird **autorisiert** (default-deny), bei Erfolg sofort ein Snapshot, danach Live-Deltas. Themen: `overview`, `machine:{id}`, `trend:{data_point_id}`.

Expand Down Expand Up @@ -649,7 +649,7 @@ FOREMAN als **offener Knoten**: ein read-only Model-Context-Protocol-Server (`sr
Das Backend-Fundament des Dashboards (Frontend folgt separat). Trennt **LIVE** (Push/WebSocket) von **ON-DEMAND/Erstbild** (Pull/HTTP, §4) und teilt einen transport-neutralen **Read-Core**. Designgrundlage: `docs/research/FOREMAN_Designstudie_Frontend.md` §5.1.

### 20.1 Geteilter Read-Core (`foreman/reads/`)
Transport-neutrale Read-only-Schicht, von MCP (F7), HTTP-Routen (§4) und WS-Push gemeinsam genutzt — keine Duplikation. `queries.py` (SELECT-Funktionen + `ReadingBucket`), `status.py` (`compose_status` + kanonischer `MachineStatus` healthy/drift_active/open_warning), `overview.py` (`build_fleet_overview(machine_ids?)` → FCSM-Status + Severity-Breakdown + Rollup), `trend.py` (`build_trend`/`build_trend_by_id` → `readings_1m` + statisches Normalband). Die MCP-Schicht (F7) ruft jetzt diesen Read-Core auf (vormals `mcp/reads.py` + `_compose_status` — verschoben, F7-Verhalten unverändert).
Transport-neutrale Read-only-Schicht, von MCP (F7), HTTP-Routen (§4) und WS-Push gemeinsam genutzt — keine Duplikation. `queries.py` (SELECT-Funktionen + `ReadingBucket`), `status.py` (`compose_status` + kanonischer `MachineStatus` healthy/drift_active/open_warning), `overview.py` (`build_fleet_overview(machine_ids?, now?)` → FCSM-Status + Severity-Breakdown + Rollup + **Eingangs-Stream-Status**), `stream.py` (`build_stream_status`/`classify_stream` → **aktiv/inaktiv des Eingangs-Live-Streams** aus dem jüngsten `simulation`-Reading gegen `STREAM_FRESH_WINDOW`=5 min; `StreamStatus{active, last_reading_at}` — gemeinsame Wahrheit von Topologie-Kachel und „Live"-Badge), `trend.py` (`build_trend`/`build_trend_by_id` → `readings_1m` + statisches Normalband). Die MCP-Schicht (F7) ruft jetzt diesen Read-Core auf (vormals `mcp/reads.py` + `_compose_status` — verschoben, F7-Verhalten unverändert).

### 20.2 Transport: Postgres LISTEN/NOTIFY (kein Polling, kein Redis)
Der separate Ingest-Prozess (§12.5) ist nicht die API → entkoppelte Push-Brücke über Postgres-NOTIFY (Stack bewusst ohne Redis/Celery).
Expand Down Expand Up @@ -870,13 +870,13 @@ Plattform-/Audit-Sicht der Sektion I. Zwei Backend-Stücke + ihre Read-APIs; bau
### 22.2 Topologie-Quelle (`src/foreman/topology/`)

- **Ehrlich abgeleitet, nichts erfunden:** drei reale Knoten-Klassen —
- **Eingänge:** distinct `data_points.source` + jüngste `readings`-Aktivität je Quelle (Richtung `liefert`). `simulation` als **intern** markiert (kein externer Peer).
- **Eingänge:** distinct `data_points.source` + jüngste `readings`-Aktivität je Quelle (Richtung `liefert`). `simulation` als **intern** markiert (kein externer Peer). Die interne `simulation`-Quelle IST der **Eingangs-Live-Stream** (digitaler Zwilling): gegen das **enge** `STREAM_FRESH_WINDOW` (5 min, `reads/stream.py`) gemessen statt des generischen `fresh_within_minutes` — **dieselbe Wahrheit wie das „Live"-Badge** (Konsistenz: „aktiv" nur, wenn der Live-Worker §12.6 wirklich tickt). Das Frontend rendert für die interne Quelle „**aktiv**" statt „verbunden" (Backend-Statusvertrag stabil, nur UI-Sprache).
- **Gedächtnis-Substrat:** Health aus einer best-effort-Live-Probe (`run_substrate_smoke`, §9; schreibt einen Smoke-Marker, per `?probe=false` abschaltbar). Richtung `beides`.
- **F7-MCP-Grenze:** Ausgang (`liest`), Aktivität aus dem Audit-Trail (`mcp_retrieval`-Einträge — Teil A speist Teil B; ohne Audit-Einsicht/Schichtleiter wird der Trail nicht gelesen).
- **Status nur wo messbar:** `verbunden`/`gestört`/`inaktiv`; wo nicht messbar → ehrlich `unbekannt`, **nie grün geraten** (Quelle ohne jüngste `readings` → `unbekannt`; veraltet → `inaktiv`).
- **[VISION]-Kategorie:** benannte Drittsysteme (ERP, Energiemanagement, externe Simulationssoftware) erscheinen NUR in einer separaten, klar markierten `vision`-Liste — nie als verbunden.
- **Hidden-Term (§8):** das Substrat heißt nach außen „Gedächtnis-Substrat" — keine internen Vokabeln in Feldwerten/Labels.
- **Read-API:** `GET /api/v1/topology` — Manager voll; Schichtleiter **nur Verbindungsstatus** (kein Audit-Bezug → MCP-Knoten ohne Audit-Details); Werker/Techniker 403. Query: `probe`, `fresh_within_minutes`.
- **Read-API:** `GET /api/v1/topology` — Manager voll; Schichtleiter **nur Verbindungsstatus** (kein Audit-Bezug → MCP-Knoten ohne Audit-Details); Werker/Techniker 403. Query: `probe`, `fresh_within_minutes` (wirkt nur auf die **externen** Quellen; die interne `simulation` nutzt fix das enge Stream-Fenster, s. o.).

### 22.3 Verifikation

Expand Down
1 change: 1 addition & 0 deletions frontend/components/alarms/alarms-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const emptyOverview: FleetOverviewOut = {
machines: [],
by_status: { healthy: 0, drift_active: 0, open_warning: 0 },
open_alarm_total: 0,
stream: { active: false, last_reading_at: null },
};

function setup(current: CurrentUser, responses: AlarmRead[][]) {
Expand Down
9 changes: 9 additions & 0 deletions frontend/components/atoms/atoms.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,13 @@ describe("ProvenanceStamp — Herkunft/Frische + AI-Act-Kennzeichnung", () => {
render(<ProvenanceStamp freshness="live" aiGenerated />);
expect(screen.getByText("KI-erzeugt")).toBeInTheDocument();
});

it("zeigt 'Verlauf' mit Stand (kein Live), wenn nur Historie vorliegt", () => {
render(<ProvenanceStamp freshness="history" stampedAt={new Date("2026-06-25T12:30:00Z")} />);
const stamp = screen.getByText(/Verlauf/);
expect(stamp).toBeInTheDocument();
// Ehrlich: kein grüner Live-Punkt über statischer Historie.
expect(document.querySelector(".bg-state-ok")).toBeNull();
expect(screen.queryByText(/Live/)).toBeNull();
});
});
8 changes: 6 additions & 2 deletions frontend/components/atoms/provenance-stamp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
// ============================================================
import { cx } from "@/lib/ui/cx";

export type Freshness = "live" | "cached";
// "live" = Live-Strom (WS offen UND Eingangs-Stream tickt) — grüner Punkt.
// "cached" = geladen, aber WS-Verbindung weg (eingefrorener Stand).
// "history"= WS verbunden, aber kein laufender Eingangs-Stream → nur Historie
// („Verlauf", kein grüner Live-Punkt). Ehrlich: kein Live-Etikett ohne Strom.
export type Freshness = "live" | "cached" | "history";

export interface ProvenanceStampProps {
freshness: Freshness;
Expand Down Expand Up @@ -41,7 +45,7 @@ export function ProvenanceStamp({
}: ProvenanceStampProps) {
const time = formatStamp(stampedAt);
const isLive = freshness === "live";
const freshnessText = isLive ? "Live" : "Gecacht";
const freshnessText = isLive ? "Live" : freshness === "history" ? "Verlauf" : "Gecacht";
const timeText = time ? (isLive ? ` · aktualisiert ${time}` : ` · Stand ${time}`) : "";

return (
Expand Down
7 changes: 6 additions & 1 deletion frontend/components/cockpit/cockpit-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,12 @@ function overview(machines: Partial<MachineStatusOut>[]): FleetOverviewOut {
for (const m of full) {
byStatus[m.status] += 1;
}
return { machines: full, by_status: byStatus, open_alarm_total: full.reduce((s, m) => s + m.open_alarm_count, 0) };
return {
machines: full,
by_status: byStatus,
open_alarm_total: full.reduce((s, m) => s + m.open_alarm_count, 0),
stream: { active: false, last_reading_at: null },
};
}

function setup(user: CurrentUser, initialData?: FleetOverviewOut, scope: CockpitScope = FLEET) {
Expand Down
30 changes: 30 additions & 0 deletions frontend/components/platform/topology-graph.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,24 @@ describe("TopologyGraph", () => {
);
expect(screen.queryByTestId("vision-zone")).toBeNull();
});

it("spricht die aktive interne Quelle auch im Lagebild 'aktiv'", () => {
render(
<TopologyGraph
model={assembleTopology(
makeTopologyView({
nodes: [
makeNode({ internal: true, status: "verbunden", label: "Simulation (intern)" }),
],
vision: [],
}),
)}
/>,
);
const graph = screen.getByTestId("topology-graph");
expect(within(graph).getByText(/aktiv · liefert/)).toBeInTheDocument();
expect(within(graph).queryByText(/verbunden/)).toBeNull();
});
});

describe("TopologyNodeMark", () => {
Expand All @@ -60,6 +78,18 @@ describe("TopologyNodeMark", () => {
expect(screen.getByText("intern")).toBeInTheDocument();
});

it("spricht eine aktive interne Quelle ehrlich 'aktiv' statt 'verbunden'", () => {
const [node] = assembleTopology(
makeTopologyView({
nodes: [makeNode({ internal: true, status: "verbunden", label: "Simulation (intern)" })],
vision: [],
}),
).inputs;
render(<TopologyNodeMark node={node!} />);
expect(screen.getByText("aktiv")).toBeInTheDocument();
expect(screen.queryByText("verbunden")).toBeNull();
});

it("markiert einen Vision-Knoten als nicht verbunden", () => {
const [node] = assembleTopology(
makeTopologyView({ nodes: [], vision: [makeVisionNode()] }),
Expand Down
4 changes: 2 additions & 2 deletions frontend/components/platform/topology-graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
// zugängliche Knoten-Liste daneben. Statisch → reduced-motion neutral.
// Architektur-Einordnung: bespoke SVG (Schicht 2, client).
// ============================================================
import { normalizeStatus } from "@/lib/platform/status";
import { connectionStatusLabel, normalizeStatus } from "@/lib/platform/status";
import type { ConnectionStatus, TopologyModel, TopologyNodeModel } from "@/lib/platform/types";
import { statusShape } from "./topology-node-mark";

Expand Down Expand Up @@ -77,7 +77,7 @@ function NodeBox({ placed }: { placed: PlacedNode }) {
{truncate(node.label)}
</text>
<text x={x + 40} y={y + 40} fontSize="11.5" fill="var(--color-fg-muted)">
{node.status}
{connectionStatusLabel(node.status, node.internal)}
{" · "}
{node.direction}
</text>
Expand Down
11 changes: 9 additions & 2 deletions frontend/components/platform/topology-node-mark.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
// Architektur-Einordnung: bespoke SVG-Atom + Knoten-Karte (Schicht 2, client).
// ============================================================
import { cx } from "@/lib/ui/cx";
import { directionPresentation, statusPresentation, type StatusGlyph as Glyph } from "@/lib/platform/status";
import {
connectionStatusLabel,
directionPresentation,
statusPresentation,
type StatusGlyph as Glyph,
} from "@/lib/platform/status";
import { nodeDetailChips } from "@/lib/platform/topology-view-model";
import type { TopologyNodeModel } from "@/lib/platform/types";

Expand Down Expand Up @@ -163,7 +168,9 @@ export function TopologyNodeMark({ node }: TopologyNodeMarkProps) {
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-caption text-fg-secondary">
<span className="inline-flex items-center gap-1.5">
<span className="text-fg-muted">Status:</span>
<span title={status.description}>{status.label}</span>
<span title={status.description}>
{connectionStatusLabel(node.status, node.internal)}
</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="text-fg-muted">Richtung:</span>
Expand Down
74 changes: 74 additions & 0 deletions frontend/components/shell/global-status-bar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// ============================================================
// FOREMAN Frontend — components/shell/global-status-bar.test.tsx
// Zweck: Sichert die KERN-Konsistenz des Auftrags: das globale „Live"-Badge
// spiegelt den ECHTEN Eingangs-Stream, nicht nur den WS-Transport. Steht
// die Verbindung über rein statischer Historie (kein tickender Worker),
// zeigt das Badge „Verlauf" — niemals „Live" (Verfassung: kein Etikett ohne
// Strom). Tickt der Stream, wird es ehrlich „Live". Transport-agnostisch
// über FakeTransport.
// ============================================================
import { act, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
usePathname: () => "/overview",
}));

import type { CurrentUser, FleetOverviewOut } from "@/lib/api/contracts";
import { SessionProvider } from "@/lib/auth/use-session";
import { RealtimeProvider } from "@/lib/realtime/realtime-context";
import { RealtimeStore } from "@/lib/realtime/realtime-store";
import { FakeTransport } from "@/lib/realtime/testing/fake-transport";

import { GlobalStatusBar } from "./global-status-bar";

const MANAGER: CurrentUser = {
id: 1,
email: "m@x.de",
role: "manager",
assigned_line_ids: [],
assigned_machine_ids: [],
};

function overviewWithStream(active: boolean, lastReadingAt: string | null): FleetOverviewOut {
return {
machines: [],
by_status: { healthy: 0, drift_active: 0, open_warning: 0 },
open_alarm_total: 0,
stream: { active, last_reading_at: lastReadingAt },
};
}

function setup() {
const transport = new FakeTransport();
const store = new RealtimeStore(transport);
render(
<SessionProvider user={MANAGER}>
<RealtimeProvider store={store}>
<GlobalStatusBar />
</RealtimeProvider>
</SessionProvider>,
);
return { transport };
}

describe("GlobalStatusBar — Live-Badge spiegelt den Eingangs-Stream", () => {
it("zeigt 'Verlauf' (kein Live), wenn die Verbindung steht, aber kein Stream tickt", async () => {
const { transport } = setup();
act(() => {
transport.emit("overview", overviewWithStream(false, "2026-06-25T12:30:00Z"));
});
expect(await screen.findByText(/Verlauf/)).toBeInTheDocument();
// Genau der verbotene Fall: kein „Live" über statischer Historie.
expect(screen.queryByText(/Live/)).toBeNull();
});

it("zeigt 'Live', wenn die Verbindung steht UND der Stream tickt", async () => {
const { transport } = setup();
act(() => {
transport.emit("overview", overviewWithStream(true, "2026-06-25T12:30:00Z"));
});
expect(await screen.findByText(/Live/)).toBeInTheDocument();
});
});
Loading
Loading