diff --git a/apps/brunch-agent/test/baseline-harness.test.ts b/apps/brunch-agent/test/baseline-harness.test.ts index 7f7d6e614d5..7eed3d23721 100644 --- a/apps/brunch-agent/test/baseline-harness.test.ts +++ b/apps/brunch-agent/test/baseline-harness.test.ts @@ -14,6 +14,9 @@ import { runNodeScript } from "./run-node-script"; import type { HarnessRunRecord } from "../../../libs/@hashintel/brunch-agent/evaluations/protocols/process-model-elicitation/baseline/harness-run.ts"; +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + const testDirectory = import.meta.dirname; const contextRoot = join( testDirectory, @@ -94,6 +97,48 @@ test("condition 5 drives the shipped elicitor through the binding and reads the captures: 1, complete: false, }); + const appliedSweepPart = run.history.messages + .flatMap((message) => message.parts) + .find( + (part) => + part.type === "dynamic-tool" && + part.toolName === "brunch_sweep" && + part.state === "output-available", + ); + if ( + appliedSweepPart?.type !== "dynamic-tool" || + appliedSweepPart.state !== "output-available" || + !isRecord(appliedSweepPart.output) + ) { + throw new Error("The applied sweep did not expose its result."); + } + expect(appliedSweepPart.output.status).toBe("applied"); + const sweepCaptures = appliedSweepPart.output.captures; + if (!Array.isArray(sweepCaptures) || !isRecord(sweepCaptures[0])) { + throw new Error("The sweep did not expose its current captures."); + } + expect(sweepCaptures).toHaveLength(1); + expect(sweepCaptures[0].status).toBe("active"); + const { evidence } = sweepCaptures[0]; + expect(Array.isArray(evidence)).toBe(true); + if (!Array.isArray(evidence) || !isRecord(evidence[0])) { + throw new Error("The capture did not expose its evidence."); + } + expect(evidence[0].excerpt).toBe(EXPERT_OBJECTIVE_QUOTE); + const { completion } = appliedSweepPart.output; + if (!isRecord(completion) || !Array.isArray(completion.failures)) { + throw new Error("The sweep did not expose its completion report."); + } + expect(completion.complete).toBe(false); + expect(completion.failures).toHaveLength(5); + expect( + completion.failures.every( + (failure) => + isRecord(failure) && + typeof failure.diagnostic === "string" && + typeof failure.message === "string", + ), + ).toBe(true); // Then the interviewer closes without asking; the runner counts three such // turns before the wrap and declares the interview stalled — no classifier. diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.test.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.test.ts new file mode 100644 index 00000000000..fce09d1658a --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.test.ts @@ -0,0 +1,247 @@ +import { expect, test } from "vitest"; + +import { createBrunchPanelTransport } from "./brunch-panel-transport"; + +import type { PetrinautAiChatTransport } from "@hashintel/petrinaut/ui"; +import type { UIMessageChunk } from "ai"; + +const sweepChunks: UIMessageChunk[] = [ + { + type: "tool-input-available", + toolCallId: "no-range", + toolName: "brunch_sweep", + input: {}, + }, + { + type: "tool-output-available", + toolCallId: "no-range", + output: { status: "no-settled-range" }, + }, + { + type: "tool-input-available", + toolCallId: "refused", + toolName: "brunch_sweep", + input: {}, + }, + { + type: "tool-output-available", + toolCallId: "refused", + output: { + status: "refused", + refusal: { + code: "evidence-quote-not-found", + message: "Use an exact quote.", + }, + }, + }, + { + type: "tool-input-available", + toolCallId: "applied", + toolName: "brunch_sweep", + input: {}, + }, + { + type: "tool-output-available", + toolCallId: "applied", + output: { + status: "applied", + appliedCaptureIds: ["capture-1"], + captures: [ + { + id: "capture-1", + status: "active", + epistemicStatus: "explicit", + confidence: "high", + content: { value: { type: "slot-asserted" } }, + evidence: [{ excerpt: "Line A runs next." }], + }, + { + id: "capture-old", + status: "superseded", + epistemicStatus: "inferred", + confidence: "medium", + content: { absence: "unknown" }, + basis: { + type: "default-rule", + description: "The earlier default.", + }, + alternativeGroup: "line-a-owner", + }, + { + id: "capture-withdrawn", + status: "retracted", + epistemicStatus: "explicit", + confidence: "high", + content: { value: "Sam" }, + evidence: [{ excerpt: "Not Sam after all." }], + supersedes: "capture-old", + }, + ], + completion: { + complete: false, + pluginVersion: "1.0.0", + revision: "revision-1", + failures: [ + { + diagnostic: "unaddressed", + nodeId: "activity:line-a", + kind: "activity", + slot: "owner", + requirement: "named", + actual: "not mentioned", + message: "The owner is missing.", + captureIds: ["capture-1"], + }, + ], + sliceNodeIds: ["activity:line-a"], + outsideSlice: [ + { + nodeId: "activity:Line-B", + kind: "activity", + open: [ + { + diagnostic: "unaddressed", + nodeId: "activity:Line-B", + kind: "activity", + slot: "Owner", + requirement: "named", + actual: "not mentioned", + message: "The owner is missing.", + captureIds: ["Capture-2"], + }, + ], + }, + ], + }, + }, + }, +]; + +const sourceTransport: PetrinautAiChatTransport = { + reconnectToStream: async () => null, + sendMessages: async () => + new ReadableStream({ + start(controller) { + for (const chunk of sweepChunks) controller.enqueue(chunk); + controller.close(); + }, + }), +}; + +const readChunks = async ( + stream: ReadableStream, +): Promise => { + const chunks: UIMessageChunk[] = []; + for await (const chunk of stream) chunks.push(chunk); + return chunks; +}; + +test("makes every Brunch sweep outcome readable in the panel", async () => { + const transport = createBrunchPanelTransport(sourceTransport); + const stream = await transport.sendMessages({} as never); + const chunks = await readChunks(stream); + const outputs = chunks.filter( + (chunk) => chunk.type === "tool-output-available", + ); + + expect(outputs.map((outputChunk) => outputChunk.output)).toEqual([ + { + status: "no-settled-range", + title: "No settled range to sweep", + detail: "The conversation has no settled user entries.", + }, + { + status: "refused", + refusal: { + code: "evidence-quote-not-found", + message: "Use an exact quote.", + }, + title: "Sweep refused", + detail: "Use an exact quote.", + items: ["Refusal: evidence-quote-not-found"], + }, + { + status: "applied", + appliedCaptureIds: ["capture-1"], + captures: [ + { + id: "capture-1", + status: "active", + epistemicStatus: "explicit", + confidence: "high", + content: { value: { type: "slot-asserted" } }, + evidence: [{ excerpt: "Line A runs next." }], + }, + { + id: "capture-old", + status: "superseded", + epistemicStatus: "inferred", + confidence: "medium", + content: { absence: "unknown" }, + basis: { + type: "default-rule", + description: "The earlier default.", + }, + alternativeGroup: "line-a-owner", + }, + { + id: "capture-withdrawn", + status: "retracted", + epistemicStatus: "explicit", + confidence: "high", + content: { value: "Sam" }, + evidence: [{ excerpt: "Not Sam after all." }], + supersedes: "capture-old", + }, + ], + completion: { + complete: false, + pluginVersion: "1.0.0", + revision: "revision-1", + failures: [ + { + diagnostic: "unaddressed", + nodeId: "activity:line-a", + kind: "activity", + slot: "owner", + requirement: "named", + actual: "not mentioned", + message: "The owner is missing.", + captureIds: ["capture-1"], + }, + ], + sliceNodeIds: ["activity:line-a"], + outsideSlice: [ + { + nodeId: "activity:Line-B", + kind: "activity", + open: [ + { + diagnostic: "unaddressed", + nodeId: "activity:Line-B", + kind: "activity", + slot: "Owner", + requirement: "named", + actual: "not mentioned", + message: "The owner is missing.", + captureIds: ["Capture-2"], + }, + ], + }, + ], + }, + title: "Sweep applied", + detail: "1 new capture · 3 total · incomplete", + items: [ + 'Capture capture-1 (active; explicit; confidence high): {"type":"slot-asserted"} — “Line A runs next.”', + "Capture capture-old (superseded; inferred; confidence medium): absence: unknown — default-rule: The earlier default.; alternative group line-a-owner", + 'Capture capture-withdrawn (retracted; explicit; confidence high): "Sam" — “Not Sam after all.”; supersedes capture-old', + "Completion: incomplete · plugin 1.0.0 · revision revision-1", + "Completion slice: activity:line-a", + "Completion gap [unaddressed] at activity:line-a.owner: needs named; actual not mentioned. The owner is missing. Captures: capture-1", + "Outside completion slice: activity:Line-B (activity); 1 open requirement", + "Outside-slice Completion gap [unaddressed] at activity:Line-B.Owner: needs named; actual not mentioned. The owner is missing. Captures: Capture-2", + ], + }, + ]); +}); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.ts new file mode 100644 index 00000000000..e9b0915734f --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.ts @@ -0,0 +1,200 @@ +import { z } from "zod"; + +import { SWEEP_TOOL_NAME } from "@hashintel/brunch-agent-transport-aisdk/client-tools"; + +import type { PetrinautAiChatTransport } from "@hashintel/petrinaut/ui"; +import type { UIMessageChunk } from "ai"; + +const completionFailureSchema = z.object({ + diagnostic: z.string(), + nodeId: z.string().optional(), + kind: z.string().optional(), + slot: z.string().optional(), + requirement: z.string(), + actual: z.string(), + message: z.string(), + captureIds: z.array(z.string()), +}); + +const completionReportSchema = z.object({ + complete: z.boolean(), + pluginVersion: z.string(), + revision: z.string(), + failures: z.array(completionFailureSchema), + sliceNodeIds: z.array(z.string()), + outsideSlice: z.array( + z.object({ + nodeId: z.string(), + kind: z.string(), + open: z.array(completionFailureSchema), + }), + ), +}); + +const captureSchema = z.object({ + id: z.string(), + status: z.enum(["active", "superseded", "retracted"]), + epistemicStatus: z.string(), + confidence: z.string(), + content: z.union([ + z.object({ value: z.unknown() }), + z.object({ absence: z.string() }), + ]), + evidence: z.array(z.object({ excerpt: z.string() })).optional(), + basis: z + .object({ + type: z.string(), + description: z.string(), + }) + .optional(), + alternativeGroup: z.string().optional(), + supersedes: z.string().optional(), +}); + +const sweepOutputSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("no-settled-range") }), + z.object({ + status: z.literal("refused"), + refusal: z.object({ + code: z.string(), + message: z.string(), + }), + }), + z.object({ + status: z.literal("applied"), + appliedCaptureIds: z.array(z.string()), + captures: z.array(captureSchema), + completion: completionReportSchema.optional(), + }), +]); + +type CompletionFailure = z.infer; +type CompletionReport = z.infer; +type VisibleCapture = z.infer; + +const formatFailure = (failure: CompletionFailure): string => { + const location = + failure.nodeId === undefined + ? "" + : ` at ${failure.nodeId}${failure.slot === undefined ? "" : `.${failure.slot}`}`; + const captures = + failure.captureIds.length === 0 + ? "" + : ` Captures: ${failure.captureIds.join(", ")}`; + return `Completion gap [${failure.diagnostic}]${location}: needs ${failure.requirement}; actual ${failure.actual}. ${failure.message}${captures}`; +}; + +const formatCapture = (capture: VisibleCapture): string => { + const content = + "value" in capture.content + ? JSON.stringify(capture.content.value) + : `absence: ${capture.content.absence}`; + const provenance = + capture.evidence !== undefined + ? capture.evidence.map((evidence) => `“${evidence.excerpt}”`).join("; ") + : capture.basis === undefined + ? "no provenance" + : `${capture.basis.type}: ${capture.basis.description}`; + const history = [ + capture.alternativeGroup === undefined + ? undefined + : `alternative group ${capture.alternativeGroup}`, + capture.supersedes === undefined + ? undefined + : `supersedes ${capture.supersedes}`, + ].filter((fact) => fact !== undefined); + return `Capture ${capture.id} (${capture.status}; ${capture.epistemicStatus}; confidence ${capture.confidence}): ${content} — ${provenance}${history.length === 0 ? "" : `; ${history.join("; ")}`}`; +}; + +const formatCompletion = (report: CompletionReport): string[] => [ + `Completion: ${report.complete ? "complete" : "incomplete"} · plugin ${report.pluginVersion} · revision ${report.revision}`, + `Completion slice: ${report.sliceNodeIds.join(", ") || "none"}`, + ...report.failures.map(formatFailure), + ...report.outsideSlice.flatMap((node) => [ + `Outside completion slice: ${node.nodeId} (${node.kind}); ${node.open.length} open requirement${node.open.length === 1 ? "" : "s"}`, + ...node.open.map((failure) => `Outside-slice ${formatFailure(failure)}`), + ]), +]; + +const summarizeSweepOutput = ( + output: unknown, +): + | { + readonly title: string; + readonly detail: string; + readonly items?: readonly string[]; + } + | undefined => { + const parsed = sweepOutputSchema.safeParse(output); + if (!parsed.success) return undefined; + + const sweep = parsed.data; + switch (sweep.status) { + case "no-settled-range": + return { + title: "No settled range to sweep", + detail: "The conversation has no settled user entries.", + }; + case "refused": + return { + title: "Sweep refused", + detail: sweep.refusal.message, + items: [`Refusal: ${sweep.refusal.code}`], + }; + case "applied": + return { + title: "Sweep applied", + detail: `${sweep.appliedCaptureIds.length} new capture${sweep.appliedCaptureIds.length === 1 ? "" : "s"} · ${sweep.captures.length} total · ${sweep.completion?.complete === true ? "complete" : "incomplete"}`, + items: [ + ...sweep.captures.map(formatCapture), + ...(sweep.completion === undefined + ? [] + : formatCompletion(sweep.completion)), + ], + }; + } +}; + +const decorateBrunchStream = ( + stream: ReadableStream, +): ReadableStream => { + const toolNamesByCallId = new Map(); + return stream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + if (chunk.type === "tool-input-available") { + toolNamesByCallId.set(chunk.toolCallId, chunk.toolName); + } + if ( + chunk.type === "tool-output-available" && + toolNamesByCallId.get(chunk.toolCallId) === SWEEP_TOOL_NAME + ) { + const summary = summarizeSweepOutput(chunk.output); + if ( + summary !== undefined && + typeof chunk.output === "object" && + chunk.output !== null + ) { + controller.enqueue({ + ...chunk, + output: { ...chunk.output, ...summary }, + }); + return; + } + } + controller.enqueue(chunk); + }, + }), + ); +}; + +export const createBrunchPanelTransport = ( + transport: PetrinautAiChatTransport, +): PetrinautAiChatTransport => ({ + reconnectToStream: async (options) => { + const stream = await transport.reconnectToStream(options); + return stream === null ? null : decorateBrunchStream(stream); + }, + sendMessages: async (options) => + decorateBrunchStream(await transport.sendMessages(options)), +}); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx index 81d6b3bbf22..540d073db8e 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx @@ -19,6 +19,7 @@ import { import { useSentryFeedbackAction } from "../sentry-feedback-button"; import { brunchAskInteractiveTool } from "./brunch-ask-interactive-tool"; +import { createBrunchPanelTransport } from "./brunch-panel-transport"; import { getOrCreateBrunchPrincipal } from "./brunch-principal"; import { useLocalStorageAiMessages } from "./use-local-storage-ai-messages"; import { @@ -83,12 +84,14 @@ const createHandle = (net: SDCPNInLocalStorage): PetrinautDocHandle => }); const petrinautAiChatTransport: PetrinautAiChatTransport = - new DefaultChatTransport({ - api: "/api/chat", - headers: () => ({ - [BRUNCH_PRINCIPAL_HEADER]: getOrCreateBrunchPrincipal(), + createBrunchPanelTransport( + new DefaultChatTransport({ + api: "/api/chat", + headers: () => ({ + [BRUNCH_PRINCIPAL_HEADER]: getOrCreateBrunchPrincipal(), + }), }), - }); + ); const getStoredSDCPNsForDisplay = ( storedSDCPNs: Record, diff --git a/libs/@hashintel/brunch-agent/docs/specs/petrinaut-integration.md b/libs/@hashintel/brunch-agent/docs/specs/petrinaut-integration.md index 76cca430e50..256b247ee2a 100644 --- a/libs/@hashintel/brunch-agent/docs/specs/petrinaut-integration.md +++ b/libs/@hashintel/brunch-agent/docs/specs/petrinaut-integration.md @@ -4,7 +4,8 @@ (`docs/adr/0004-in-petrinaut-staging-and-the-monorepo-import.md`) · **Supersedes**: `recommendation-demo-vehicle.md` as the September staging plan · **Evidence base**: the Petrinaut survey (FE-1358, `research/petrinaut-survey.md`), re-verified against -`hashintel/hash` source on 2026-08-18. +`hashintel/hash` source on 2026-08-18 · **Amended**: FE-1506 (stable UI and voice attach +contract). ## Problem Statement @@ -22,7 +23,7 @@ storage. The problem is connecting the second to the first without rebuilding ei The brunch elicitor runs as a **remote server** built on the harness + `binding-flue`. The demo site swaps its `aiAssistant.transport` to point at that server; everything else in the panel — rendering, the diagnostics decorator, client-side tool execution — is reused as-is. -The elicitor drives Petrinaut's editor through the **existing client-executed tool surface** +The elicitor drives Petrinaut's editor through the **existing UI-executed tool surface** (schemas imported from `petrinaut-core`), riding the harness's turn-suspension protocol: a turn ends with tool calls pending, the panel executes them, and the outputs return on the next dispatch. Sessions, captures, and IRs persist server-side, keyed to an opaque principal the @@ -45,6 +46,24 @@ which the design needs anyway: 4. **The artifact seam** (`parseSDCPNFile` / `sdcpnFileSchema`): unchanged; net validity checked in CI through the pure parser. +## Attach Contract + +The panel and the voice edge attach to Brunch through one stable surface: + +1. **Chat stream**: the UI sends `POST /api/chat`; a successful response is an AI SDK v6 + UI-message stream over HTTP/SSE. +2. **Question affordance**: the UI-executed tool is named `brunch_ask`. Its input schema is + `{ question: non-empty string }`; its submitted output schema is + `{ answer: non-empty string }`. +3. **Principal identity**: every request carries one non-empty, opaque principal in the + `x-brunch-principal` header. The current UI shell keeps that value in localStorage so it is + stable across reloads; replacing the local UID with authenticated identity must preserve the + same request-level ownership semantics. + +These three parts change only with notice to the panel and voice-edge owners. A provider-specific +voice requirement does not silently alter this surface; it arrives as a generic UI-shell extension +or triggers an explicit contract revision. + ## User Stories 1. As a demo.petrinaut.org visitor, I want to converse with the elicitor in the same chat @@ -99,8 +118,8 @@ which the design needs anyway: **Topology and packaging** - The elicitor server is a thin host-authored agent (spec §13) around the harness library, - deployed remotely; the demo site addresses it cross-origin, bypassing the site's own - `/api/chat` and Vercel function limits. + deployed remotely; the demo site's same-origin `/api/chat` route reaches that server without + routing through the stock Petrinaut assistant or its prompt. - Implemented by FE-1436 (the durable AI SDK transport): package `transport-aisdk` is the server end of the ui shell's reply transport. It translates harness-level parts to AI SDK v6 UI-message-stream chunks, using the `ai` package for stream @@ -137,7 +156,7 @@ which the design needs anyway: - The elicitor's Petrinaut tools are generated from `petrinaut-core`'s exported tool schemas, so the tool surface tracks Petrinaut's own contract rather than a hand-copied one. - The panel executes only tool names it knows and throws on unknowns; brunch-only tools - therefore execute server-side. If a client-executed brunch tool is ever needed, the change + therefore execute server-side. If a UI-executed brunch tool is ever needed, the change is a generic host-supplied-handlers extension to the `aiAssistant` prop (post-import, per ADR-0004's boundary discipline). diff --git a/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts b/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts index 90f50222bdc..4eb130de583 100644 --- a/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts +++ b/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts @@ -41,6 +41,7 @@ import { createInitialSweepState, decidePendingAffordance, decideSettlementTrigger, + deriveCaptureStatus, evaluateCompletion, foldElicitedModel, mintAskAffordance, @@ -132,9 +133,7 @@ export function useElicitation( const report = evaluateCompletion(model, demands); const sweepList = buildSweepList(model, report, slotModel.patterns); return { - complete: report.complete, - revision: report.revision, - pluginVersion: report.pluginVersion, + ...report, unsatisfied: report.failures.length, unmapped: model.unmapped, cue: buildCompletionCueSignal(model, report, sweepList).body, @@ -247,13 +246,16 @@ export function useElicitation( applied.snapshot, session.sessionId, ); + const appliedCaptureIds = + "appliedCaptureIds" in applied.value + ? applied.value.appliedCaptureIds + : []; + const completion = + slotModel === undefined ? undefined : completionCue(applied.snapshot); return { output: { status: "applied" as const, - appliedCaptureIds: - "appliedCaptureIds" in applied.value - ? applied.value.appliedCaptureIds - : [], + appliedCaptureIds, skippedDedupKeys: "skippedDedupKeys" in applied.value ? applied.value.skippedDedupKeys @@ -262,9 +264,12 @@ export function useElicitation( ...("advisories" in applied.value ? applied.value.advisories : []), ...computeUnaccountedAskAdvisories(range, accountedEntryIds), ], - ...(slotModel === undefined - ? {} - : { completion: completionCue(applied.snapshot) }), + captures: applied.snapshot.captures.map((capture) => + Object.assign({}, capture, { + status: deriveCaptureStatus(applied.snapshot, capture.id), + }), + ), + ...(completion === undefined ? {} : { completion }), }, }; }, diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/client-tools.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/client-tools.ts index d2de4a7c9d8..a61e161d6dd 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/client-tools.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/client-tools.ts @@ -11,6 +11,7 @@ import { } from "@hashintel/brunch-agent/client-tools"; export const ASK_TOOL_NAME = toolName("ask"); +export const SWEEP_TOOL_NAME = toolName("sweep"); export type BrunchAskInput = v.InferOutput; export type BrunchAskOutput = v.InferOutput; diff --git a/libs/@hashintel/petrinaut/docs/ai-assistant.md b/libs/@hashintel/petrinaut/docs/ai-assistant.md index a23a0f8672e..926e839142c 100644 --- a/libs/@hashintel/petrinaut/docs/ai-assistant.md +++ b/libs/@hashintel/petrinaut/docs/ai-assistant.md @@ -32,7 +32,10 @@ The assistant has tools for inspecting and modifying the current net. You'll see - **Host-specific questions and actions** -- an application embedding Petrinaut may add interactive widgets. For example, an elicitation assistant can ask a structured question inline and continue after you submit the answer. The - control stays visible as a read-only record of your submitted value. + control stays visible as a read-only record of your submitted value. During + elicitation, inline sweep cards may also list the facts captured so far, + whether earlier facts were superseded or retracted, and any requirements that + still prevent completion. Clicking a mutation card usually selects the entity it touched (place, transition, scenario, metric, etc.) so you can inspect what changed.