diff --git a/backend/agents/eda.yaml b/backend/agents/eda.yaml index b722668..9272716 100644 --- a/backend/agents/eda.yaml +++ b/backend/agents/eda.yaml @@ -18,6 +18,7 @@ skills: - name: use-skill - name: tasks - name: show-html + - name: report-eda-findings # Experiment lifecycle (agent-declared) - name: list-project-datasets - name: register-raw-dataset @@ -181,6 +182,16 @@ system: | (`notebooks/data-overview.ipynb`, etc.). - Include a clear recommendation for the target column and problem type - Flag any data quality issues the next agent should know about + 7. IMMEDIATELY after writing report.md, call `report-eda-findings` ONCE + with the full batch of findings you just narrated — leakage risks, + class imbalance, high-cardinality columns, multicollinearity, missing + values, outliers, duplicates, ID-like columns, skewed targets. Each + finding needs the affected `columns` and a `recommendation` phrased as + a concrete instruction data_prep could execute verbatim — the studio + renders these as cards with an "Apply in prep" button. Report the SAME + findings as the prose: this is the structured view of report.md, not a + new analysis. If the data is genuinely clean, one `info` finding saying + so is fine. ## Experiment lifecycle (secondary) diff --git a/backend/skills/report-eda-findings/SKILL.md b/backend/skills/report-eda-findings/SKILL.md new file mode 100644 index 0000000..7b0feb6 --- /dev/null +++ b/backend/skills/report-eda-findings/SKILL.md @@ -0,0 +1,40 @@ +--- +name: report-eda-findings +description: > + Publish EDA findings as structured items (finding type + affected columns + + recommendation) so the studio renders them as actionable cards with an + "Apply in prep" button — in addition to the prose report.md. +when_to_use: > + At the end of an EDA pass, right after writing report.md. One call with the + full findings list; call again later only if new findings emerge. +version: '0.1' +kind: capability +--- + +# report-eda-findings + +Structured companion to the prose EDA report (issue #111). Each finding +becomes a canvas card the user can act on with one click — the "Apply in +prep" button pre-fills the orchestrator prompt with your `recommendation`, +so write it as a concrete, self-contained data_prep instruction. + +## Finding shape + +- `finding_type` — one of `leakage`, `class_imbalance`, `high_cardinality`, + `multicollinearity`, `missing_values`, `outliers`, `duplicates`, + `skewed_target`, `id_column`, `constant_column`, `datetime_leakage`, + `other`. +- `columns` — the affected column names (empty for dataset-wide findings). +- `severity` — `info` | `warning` | `critical` (default `warning`). +- `summary` — one or two sentences: what you observed, with numbers. +- `recommendation` — the concrete prep action, phrased as an instruction + data_prep can execute verbatim (e.g. "Drop `customer_id` before modeling — + it's a unique ID that perfectly memorizes the target."). + +## Rules + +- Report the SAME findings your report.md narrates — this is the structured + view of them, not a different analysis. +- One call with the whole batch; up to 50 findings. +- Every finding needs an actionable `recommendation` — if there is nothing + to do about it, it belongs in report.md prose, not here. diff --git a/backend/skills/report-eda-findings/handler.py b/backend/skills/report-eda-findings/handler.py new file mode 100644 index 0000000..c3d2db2 --- /dev/null +++ b/backend/skills/report-eda-findings/handler.py @@ -0,0 +1,164 @@ +"""report-eda-findings skill — structured EDA findings for canvas cards. + +Normalizes the agent's findings batch and publishes a single `eda_findings` +event (SSE + persisted as a system Message, so reloads restore the cards). +The prose report.md flow is untouched — this is an additive channel. +""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + +_MAX_FINDINGS = 50 +_MAX_COLUMNS = 40 +_MAX_TEXT_CHARS = 2000 + +VALID_FINDING_TYPES = { + "leakage", + "class_imbalance", + "high_cardinality", + "multicollinearity", + "missing_values", + "outliers", + "duplicates", + "skewed_target", + "id_column", + "constant_column", + "datetime_leakage", + "other", +} + +VALID_SEVERITIES = {"info", "warning", "critical"} + + +def _clip(value, limit: int = _MAX_TEXT_CHARS) -> str: + text = str(value or "").strip() + return text[:limit] + + +def normalize_finding(raw: dict) -> dict | None: + """Coerce one raw finding into the canonical wire shape, or None if it + lacks the actionable minimum (summary + recommendation).""" + if not isinstance(raw, dict): + return None + summary = _clip(raw.get("summary")) + recommendation = _clip(raw.get("recommendation")) + if not summary or not recommendation: + return None + + finding_type = str(raw.get("finding_type") or "").strip() + if finding_type not in VALID_FINDING_TYPES: + finding_type = "other" + + severity = str(raw.get("severity") or "").strip() + if severity not in VALID_SEVERITIES: + severity = "warning" + + columns_raw = raw.get("columns") or [] + if not isinstance(columns_raw, list): + columns_raw = [columns_raw] + columns = [_clip(c, 200) for c in columns_raw[:_MAX_COLUMNS] if _clip(c, 200)] + + return { + "finding_type": finding_type, + "columns": columns, + "severity": severity, + "summary": summary, + "recommendation": recommendation, + } + + +def create_handler( + session_id: str, + publish_fn, + parent_agent_type: str, + parent_agent_id: str = "root", + parent_parent_agent_id: str | None = None, + current_depth: int = 0, + stage: str = "", + **kwargs, +): + agent_meta = { + "agent_id": parent_agent_id, + "agent_type": parent_agent_type, + "parent_agent_id": parent_parent_agent_id, + "depth": current_depth, + } + + async def handler(args: dict): + raw = args.get("findings") + if not isinstance(raw, list) or not raw: + return { + "content": [ + { + "type": "text", + "text": "findings must be a non-empty array of finding objects", + } + ], + "is_error": True, + } + + findings = [] + dropped = 0 + for item in raw[:_MAX_FINDINGS]: + norm = normalize_finding(item) + if norm is None: + dropped += 1 + continue + findings.append(norm) + # Items past the cap are valid findings we silently truncated, not + # schema failures — report them separately so the agent doesn't + # mistake the cap for a formatting problem and retry. + capped = max(0, len(raw) - _MAX_FINDINGS) + + if not findings: + return { + "content": [ + { + "type": "text", + "text": ( + "No valid findings: every item needs a non-empty " + "`summary` and `recommendation`." + ), + } + ], + "is_error": True, + } + + await publish_fn( + session_id, + "eda_findings", + { + "content": f"{len(findings)} structured EDA finding(s) published", + "findings": findings, + "count": len(findings), + "stage": stage or parent_agent_type, + }, + role="system", + agent_meta=agent_meta, + ) + + notes = [] + if dropped: + notes.append(f"{dropped} invalid item(s) dropped") + if capped: + notes.append( + f"{capped} item(s) omitted over the {_MAX_FINDINGS}-finding cap" + ) + note = f" ({'; '.join(notes)})" if notes else "" + return { + "content": [ + { + "type": "text", + "text": ( + f"Published {len(findings)} structured EDA finding(s) to " + f"the studio canvas{note}. The user can apply each " + "recommendation in prep with one click." + ), + } + ] + } + + return handler diff --git a/backend/skills/report-eda-findings/schema.yaml b/backend/skills/report-eda-findings/schema.yaml new file mode 100644 index 0000000..73bf5d7 --- /dev/null +++ b/backend/skills/report-eda-findings/schema.yaml @@ -0,0 +1,48 @@ +type: object +properties: + findings: + type: array + description: The full batch of structured EDA findings for this pass. + items: + type: object + properties: + finding_type: + type: string + enum: + - leakage + - class_imbalance + - high_cardinality + - multicollinearity + - missing_values + - outliers + - duplicates + - skewed_target + - id_column + - constant_column + - datetime_leakage + - other + description: What class of issue this is. Drives the card's badge. + columns: + type: array + items: + type: string + description: Affected column names. Empty for dataset-wide findings. + severity: + type: string + enum: [info, warning, critical] + description: How urgent this is for the user. Defaults to warning. + summary: + type: string + description: One or two sentences describing the observation, with numbers. + recommendation: + type: string + description: > + Concrete prep action, phrased as an instruction data_prep can + execute verbatim. Rendered behind the card's "Apply in prep" + button. + required: + - finding_type + - summary + - recommendation +required: + - findings diff --git a/backend/tests/test_eda_findings.py b/backend/tests/test_eda_findings.py new file mode 100644 index 0000000..d5a1f0a --- /dev/null +++ b/backend/tests/test_eda_findings.py @@ -0,0 +1,165 @@ +"""Structured EDA findings tests (issue #111).""" + +import pytest + +from services.agent.agents import get_agent_skills +from services.skills import get_skill, load_handler + +SLUG = "report-eda-findings" + + +class _PublishRecorder: + def __init__(self): + self.events = [] + + async def __call__(self, session_id, event_type, data, role=None, **kwargs): + self.events.append( + {"session_id": session_id, "type": event_type, "data": data, "role": role} + ) + + +def _make_handler(recorder: _PublishRecorder): + create_handler = load_handler(SLUG) + return create_handler( + session_id="sess-eda-1", + publish_fn=recorder, + parent_agent_type="eda", + parent_agent_id="eda-1", + parent_parent_agent_id="root", + current_depth=1, + stage="eda", + ) + + +# --------------------------------------------------------------------------- +# Wiring +# --------------------------------------------------------------------------- + + +def test_skill_is_discoverable_with_schema(): + skill = get_skill(SLUG) + assert skill.has_handler + assert skill.schema["required"] == ["findings"] + item_schema = skill.schema["properties"]["findings"]["items"] + assert set(item_schema["required"]) == {"finding_type", "summary", "recommendation"} + + +def test_eda_agent_declares_the_skill(): + assert SLUG in get_agent_skills("eda") + + +def test_other_agents_untouched(): + """Default behavior elsewhere is unchanged: only eda gains the skill.""" + for agent in ( + "chat", + "orchestrator", + "data_prep", + "feature_eng", + "trainer", + "reviewer", + ): + assert SLUG not in get_agent_skills(agent), agent + + +# --------------------------------------------------------------------------- +# Handler +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_handler_publishes_normalized_findings(): + recorder = _PublishRecorder() + handler = _make_handler(recorder) + result = await handler( + { + "findings": [ + { + "finding_type": "leakage", + "columns": ["customer_id"], + "severity": "critical", + "summary": "customer_id perfectly predicts the target.", + "recommendation": "Drop `customer_id` before modeling.", + }, + { + "finding_type": "class_imbalance", + "columns": ["churned"], + "summary": "Positive class is 4.2% of rows.", + "recommendation": "Use stratified splits and class weights.", + }, + ] + } + ) + assert not result.get("is_error") + assert "Published 2 structured EDA finding(s)" in result["content"][0]["text"] + + assert len(recorder.events) == 1 + ev = recorder.events[0] + assert ev["type"] == "eda_findings" + assert ev["role"] == "system" + data = ev["data"] + assert data["count"] == 2 + assert data["stage"] == "eda" + f0, f1 = data["findings"] + assert f0["finding_type"] == "leakage" + assert f0["severity"] == "critical" + assert f0["columns"] == ["customer_id"] + # severity defaulted when omitted + assert f1["severity"] == "warning" + + +@pytest.mark.asyncio +async def test_handler_coerces_malformed_fields(): + recorder = _PublishRecorder() + handler = _make_handler(recorder) + result = await handler( + { + "findings": [ + { + "finding_type": "not-a-real-type", + "columns": "single_col", # not a list + "severity": "apocalyptic", + "summary": " Something odd. ", + "recommendation": "Handle it.", + }, + {"finding_type": "outliers", "summary": "", "recommendation": "x"}, + ] + } + ) + assert not result.get("is_error") + assert "(1 invalid item(s) dropped)" in result["content"][0]["text"] + data = recorder.events[0]["data"] + assert data["count"] == 1 + f = data["findings"][0] + assert f["finding_type"] == "other" # unknown type coerced + assert f["severity"] == "warning" # unknown severity coerced + assert f["columns"] == ["single_col"] # scalar wrapped + assert f["summary"] == "Something odd." # stripped + + +@pytest.mark.asyncio +async def test_handler_rejects_empty_batch(): + recorder = _PublishRecorder() + handler = _make_handler(recorder) + for bad in ({}, {"findings": []}, {"findings": "nope"}): + result = await handler(bad) + assert result["is_error"] + # all-invalid items are also an error, and NOTHING is published + result = await handler({"findings": [{"summary": "", "recommendation": ""}]}) + assert result["is_error"] + assert recorder.events == [] + + +@pytest.mark.asyncio +async def test_handler_caps_batch_size(): + recorder = _PublishRecorder() + handler = _make_handler(recorder) + findings = [ + {"finding_type": "other", "summary": f"s{i}", "recommendation": f"r{i}"} + for i in range(60) + ] + result = await handler({"findings": findings}) + assert not result.get("is_error") + assert recorder.events[0]["data"]["count"] == 50 + assert ( + "(10 item(s) omitted over the 50-finding cap)" in result["content"][0]["text"] + ) diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 40f1168..fe5696e 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -4,8 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useApp } from '@/lib/AppContext'; import { SSEStreamProvider } from '@/lib/SSEStreamContext'; import { api } from '@/lib/api'; -import type { Mention, Draft, TaskCreatePayload, TaskUpdatePayload } from '@/lib/types'; -import { draftToWire, isDraftEmpty, draftToPlainText } from '@/lib/mentions'; +import type { EdaFinding, Mention, Draft, TaskCreatePayload, TaskUpdatePayload } from '@/lib/types'; +import { appendTextToDraft, draftToWire, isDraftEmpty, draftToPlainText } from '@/lib/mentions'; import { ImperativePanelHandle, Panel, @@ -113,6 +113,7 @@ function HomePageContent() { generatedFiles, fileTree, htmlArtifacts, + edaFindings, metricPoints, chartConfig, logEvents, @@ -239,6 +240,17 @@ function HomePageContent() { } }; + // "Apply in prep" (issue #111): turn a structured EDA finding into a + // data_prep instruction and pre-fill the chat input with it. The user + // reviews/edits, then sends — routing through the orchestrator as usual. + // Multiple clicks stack instructions line-by-line into one prompt. + const handleApplyInPrep = useCallback((finding: EdaFinding) => { + const cols = + finding.columns.length > 0 ? ` on ${finding.columns.map((c) => `\`${c}\``).join(', ')}` : ''; + const line = `In data prep, address the EDA finding [${finding.finding_type}]${cols}: ${finding.recommendation}`; + setDraft((prev) => appendTextToDraft(prev, isDraftEmpty(prev) ? line : `\n${line}`)); + }, []); + // Resume / retry an interrupted session. The chat feedback (status bubble + // spinner) comes back over SSE via the `session_resumed` event, so this // handler only fires the request and surfaces failures. @@ -753,6 +765,8 @@ function HomePageContent() { chartConfig={chartConfig} logEvents={logEvents} htmlArtifacts={htmlArtifacts} + edaFindings={edaFindings} + onApplyInPrep={handleApplyInPrep} sessionState={sessionState} onClose={collapseCanvas} /> diff --git a/frontend/src/components/workspace/EdaFindingsPanel.tsx b/frontend/src/components/workspace/EdaFindingsPanel.tsx new file mode 100644 index 0000000..3b23122 --- /dev/null +++ b/frontend/src/components/workspace/EdaFindingsPanel.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { useState } from 'react'; +import { AlertTriangle, Check, Info, OctagonAlert, Wand2 } from 'lucide-react'; +import type { EdaFinding } from '@/lib/types'; + +// --------------------------------------------------------------------------- +// EdaFindingsPanel — canvas action cards for structured EDA findings +// (issue #111). Each card carries the finding type, affected columns, and a +// concrete recommendation; "Apply in prep" hands the recommendation to the +// chat input, pre-filled as a data_prep instruction for the orchestrator. +// --------------------------------------------------------------------------- + +const TYPE_LABEL: Record = { + leakage: 'Leakage risk', + class_imbalance: 'Class imbalance', + high_cardinality: 'High cardinality', + multicollinearity: 'Multicollinearity', + missing_values: 'Missing values', + outliers: 'Outliers', + duplicates: 'Duplicates', + skewed_target: 'Skewed target', + id_column: 'ID-like column', + constant_column: 'Constant column', + datetime_leakage: 'Datetime leakage', + other: 'Finding', +}; + +const SEVERITY_STYLE: Record< + EdaFinding['severity'], + { border: string; badge: string; icon: typeof Info } +> = { + critical: { + border: 'border-rose-500/30', + badge: 'bg-rose-500/15 text-rose-300', + icon: OctagonAlert, + }, + warning: { + border: 'border-amber-500/25', + badge: 'bg-amber-500/15 text-amber-300', + icon: AlertTriangle, + }, + info: { + border: 'border-sky-500/25', + badge: 'bg-sky-500/15 text-sky-300', + icon: Info, + }, +}; + +function FindingCard({ + finding, + onApplyInPrep, +}: { + finding: EdaFinding; + onApplyInPrep: (finding: EdaFinding) => void; +}) { + const [applied, setApplied] = useState(false); + const style = SEVERITY_STYLE[finding.severity] ?? SEVERITY_STYLE.warning; + const SeverityIcon = style.icon; + + return ( +
+
+ + + {TYPE_LABEL[finding.finding_type] || TYPE_LABEL.other} + + + {finding.severity} + +
+ + {finding.columns.length > 0 && ( +
+ {finding.columns.map((c) => ( + + {c} + + ))} +
+ )} + +

{finding.summary}

+ +
+ Recommend + {finding.recommendation} +
+ +
+ +
+
+ ); +} + +export function EdaFindingsPanel({ + findings, + onApplyInPrep, +}: { + findings: EdaFinding[]; + onApplyInPrep: (finding: EdaFinding) => void; +}) { + // Critical first, then warnings, then info — stable within each bucket. + const order = { critical: 0, warning: 1, info: 2 } as const; + const sorted = [...findings].sort((a, b) => (order[a.severity] ?? 1) - (order[b.severity] ?? 1)); + + return ( +
+
+ Structured findings from the EDA pass. “Apply in prep” pre-fills the chat with a data-prep + instruction — review it, then send. +
+
+ {sorted.map((f) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/workspace/WorkspaceCanvas.tsx b/frontend/src/components/workspace/WorkspaceCanvas.tsx index 471b097..4878bb8 100644 --- a/frontend/src/components/workspace/WorkspaceCanvas.tsx +++ b/frontend/src/components/workspace/WorkspaceCanvas.tsx @@ -10,11 +10,13 @@ import { FolderOpen, GitBranch, Globe, + ListChecks, X, } from 'lucide-react'; import { api } from '@/lib/api'; import type { ChartConfig, + EdaFinding, FileTreeNode, GeneratedFile, HtmlArtifact, @@ -29,6 +31,7 @@ import NodeMetadataPanel from '@/components/lineage/NodeMetadataPanel'; import FileTreeRow from '@/components/workspace/FileTreeRow'; import FileViewer from '@/components/workspace/FileViewer'; import HtmlPanel from '@/components/workspace/HtmlPanel'; +import { EdaFindingsPanel } from '@/components/workspace/EdaFindingsPanel'; import MetricsPanel from '@/components/workspace/MetricsPanel'; import ReportMarkdown from '@/components/workspace/ReportMarkdown'; import { getFileIconInfo } from '@/components/workspace/fileIcons'; @@ -43,7 +46,7 @@ interface OpenTab { label: string; icon: typeof FileText; iconColor: string; - type: 'file' | 'report' | 'metrics' | 'lineage' | 'html'; + type: 'file' | 'report' | 'metrics' | 'lineage' | 'html' | 'findings'; /** For type='html': the artifact key (also the suffix of the tab id). */ htmlKey?: string; } @@ -51,6 +54,7 @@ interface OpenTab { const REPORT_TAB_ID = '__report__'; const METRICS_TAB_ID = '__metrics__'; const LINEAGE_TAB_ID = '__lineage__'; +const FINDINGS_TAB_ID = '__findings__'; const HTML_TAB_PREFIX = '__html__:'; export default function WorkspaceCanvas({ @@ -64,6 +68,8 @@ export default function WorkspaceCanvas({ chartConfig, logEvents, htmlArtifacts, + edaFindings, + onApplyInPrep, sessionState, onClose, }: { @@ -77,6 +83,10 @@ export default function WorkspaceCanvas({ chartConfig: ChartConfig | null; logEvents: LogEvent[]; htmlArtifacts: Map; + /** Structured EDA findings (issue #111) — rendered as action cards. */ + edaFindings: EdaFinding[]; + /** "Apply in prep": pre-fill the chat input with a data_prep instruction. */ + onApplyInPrep: (finding: EdaFinding) => void; sessionState: string; onClose: () => void; }) { @@ -286,6 +296,28 @@ export default function WorkspaceCanvas({ } }, [canvasContent, canvasTitle]); + // Auto-open the findings tab when structured EDA findings arrive. Only + // steals focus when no tab is active — same contract as report/metrics. + const hasFindings = edaFindings.length > 0; + useEffect(() => { + if (hasFindings) { + setOpenTabs((prev) => { + if (prev.find((t) => t.id === FINDINGS_TAB_ID)) return prev; + return [ + ...prev, + { + id: FINDINGS_TAB_ID, + label: 'Findings', + icon: ListChecks, + iconColor: 'text-amber-400', + type: 'findings', + }, + ]; + }); + setActiveTabId((prev) => prev || FINDINGS_TAB_ID); + } + }, [hasFindings]); + // Auto-open metrics tab when the first metric OR rich log payload arrives const hasMetrics = metricPoints.length > 0 || logEvents.length > 0; useEffect(() => { @@ -659,6 +691,8 @@ export default function WorkspaceCanvas({ ) : tab.type === 'html' ? ( + ) : tab.type === 'findings' ? ( + ) : null} ); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 33381e3..9ccafac 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -442,6 +442,33 @@ export interface SessionResumedData { mode?: 'resume' | 'retry'; prior_state?: string; } +export type EdaFindingType = + | 'leakage' + | 'class_imbalance' + | 'high_cardinality' + | 'multicollinearity' + | 'missing_values' + | 'outliers' + | 'duplicates' + | 'skewed_target' + | 'id_column' + | 'constant_column' + | 'datetime_leakage' + | 'other'; +export interface EdaFinding { + finding_type: EdaFindingType; + columns: string[]; + severity: 'info' | 'warning' | 'critical'; + summary: string; + /** Concrete prep instruction — the "Apply in prep" payload. */ + recommendation: string; +} +export interface EdaFindingsData { + findings?: EdaFinding[]; + count?: number; + stage?: string; + content?: string; +} export type ApprovalKind = 'target_column' | 'prep_plan' | 'model_shortlist' | 'other'; export interface ApprovalRequestData { approval_id?: string; @@ -492,6 +519,7 @@ export type SSEEvent = | { type: 'clarification_resolved'; data: ClarificationResolvedData } | { type: 'approval_request'; data: ApprovalRequestData } | { type: 'approval_resolved'; data: ApprovalResolvedData } + | { type: 'eda_findings'; data: EdaFindingsData } | { type: 'agent_tool_call'; data: AgentToolCallData } | { type: 'clarification_exchange'; data: ClarificationExchangeData } | { type: 'notebook.created'; data: NotebookCreatedData } diff --git a/frontend/src/lib/useSessionStream.ts b/frontend/src/lib/useSessionStream.ts index ed6a398..733a982 100644 --- a/frontend/src/lib/useSessionStream.ts +++ b/frontend/src/lib/useSessionStream.ts @@ -16,6 +16,7 @@ import type { GeneratedFile, UsageEvent, BudgetInfo, + EdaFinding, } from '@/lib/types'; import { type ChatItem, metaStr, metaNum } from '@/lib/chatItems'; import type { ActiveAgent } from '@/components/AgentStatusIndicator'; @@ -55,6 +56,8 @@ export interface SessionStream { generatedFiles: GeneratedFile[]; fileTree: FileTreeNode; htmlArtifacts: Map; + /** Structured EDA findings (issue #111) — rendered as canvas action cards. */ + edaFindings: EdaFinding[]; // Metrics state metricPoints: MetricPoint[]; chartConfig: ChartConfig | null; @@ -132,6 +135,11 @@ export function useSessionStream( // Agent-published HTML artifacts: keyed by `key` so regenerating an // artifact in the same session overwrites instead of piling tabs. const [htmlArtifacts, setHtmlArtifacts] = useState>(() => new Map()); + // Structured EDA findings (issue #111). Appended per eda_findings batch, + // deduped on (type + columns + summary) so a backend resend or a re-run + // publishing the same findings doesn't double the cards. + const [edaFindings, setEdaFindings] = useState([]); + const edaFindingKeysRef = useRef(new Set()); // Whether the user is scrolled near the bottom of the chat pane. The // auto-scroll effect (in ChatPane) only fires while this stays true — @@ -171,6 +179,37 @@ export function useSessionStream( ]); }, []); + // Append a batch of structured EDA findings (live SSE or reload-hydrate), + // deduped so resends don't double the cards. Opens the canvas when new + // cards land — same contract as metrics/log events. + const appendEdaFindings = useCallback( + (items: unknown) => { + if (!Array.isArray(items)) return; + const fresh: EdaFinding[] = []; + for (const raw of items) { + const f = raw as Partial | null; + if (!f || typeof f.summary !== 'string' || typeof f.recommendation !== 'string') continue; + if (!f.summary || !f.recommendation) continue; + const columns = Array.isArray(f.columns) ? f.columns.map(String) : []; + const key = `${f.finding_type || 'other'}:${columns.join(',')}:${f.summary}`; + if (edaFindingKeysRef.current.has(key)) continue; + edaFindingKeysRef.current.add(key); + fresh.push({ + finding_type: f.finding_type || 'other', + columns, + severity: f.severity === 'info' || f.severity === 'critical' ? f.severity : 'warning', + summary: f.summary, + recommendation: f.recommendation, + }); + } + if (fresh.length > 0) { + setEdaFindings((prev) => [...prev, ...fresh]); + openCanvas(); + } + }, + [openCanvas], + ); + // --------------------------------------------------------------------------- // SSE connection // --------------------------------------------------------------------------- @@ -744,6 +783,11 @@ export function useSessionStream( ); break; } + // Structured EDA findings (issue #111) — canvas action cards. + case 'eda_findings': { + appendEdaFindings(event.data.findings); + break; + } // HITL approval gate (issue #108): agent posted a consequential // decision and is blocked until the user approves or edits it. case 'approval_request': { @@ -894,11 +938,12 @@ export function useSessionStream( // `connectSSE` is itself a dep of the session-load effect below — a new // identity would re-run that effect, tearing down the EventSource and // reloading the session. Verified stable: `addItem` (useCallback []), - // `openCanvas` (page-level useCallback [], per the options contract), - // `publish` (SSEStreamContext useCallback []), `refreshExperiments` - // (AppContext useCallback []), `setIsRunning` (raw useState setter). + // `appendEdaFindings` (useCallback []), `openCanvas` (page-level + // useCallback [], per the options contract), `publish` (SSEStreamContext + // useCallback []), `refreshExperiments` (AppContext useCallback []), + // `setIsRunning` (raw useState setter). // If you add a dep, keep it stable or the invariant breaks silently. - [addItem, openCanvas, publish, refreshExperiments, setIsRunning], + [addItem, appendEdaFindings, openCanvas, publish, refreshExperiments, setIsRunning], ); // --------------------------------------------------------------------------- @@ -928,6 +973,8 @@ export function useSessionStream( setLogEvents([]); logEventKeysRef.current = new Set(); setHtmlArtifacts(new Map()); + setEdaFindings([]); + edaFindingKeysRef.current = new Set(); // Critical: clear per-session agent indicators. If we don't, the previous // session's running sub-agents leak into the new one and `agent_message` // events get mis-tagged with the wrong agent_type (the stale entry from @@ -1012,6 +1059,7 @@ export function useSessionStream( let restoredCanvasOpen = false; let restoredFiles: (GeneratedFile & { _stage?: string })[] = []; const restoredHtmlArtifacts = new Map(); + const restoredEdaFindings: unknown[] = []; if (sessionData.messages?.length > 0) { // Events persisted for introspection/telemetry only — never rendered as bubbles. @@ -1210,6 +1258,11 @@ export function useSessionStream( }, }), ); + } else if (eventType === 'eda_findings') { + // Structured EDA findings (issue #111) — restored into the + // canvas cards, never rendered as a chat bubble. + const items = msg.metadata?.findings; + if (Array.isArray(items)) restoredEdaFindings.push(...items); } else if (eventType === 'approval_request') { // Restore the approval card (pending until a matching // approval_resolved row flips it). Critical for reload-mid-gate: @@ -1318,6 +1371,7 @@ export function useSessionStream( } setChatItems(restored); + appendEdaFindings(restoredEdaFindings); setCanvasContent(restoredCanvasContent); setCanvasTitle(restoredCanvasTitle); if (restoredHtmlArtifacts.size > 0) { @@ -1410,6 +1464,7 @@ export function useSessionStream( activeSessionId, connectSSE, addItem, + appendEdaFindings, resetSessionState, openCanvas, setIsRunning, @@ -1429,6 +1484,7 @@ export function useSessionStream( generatedFiles, fileTree, htmlArtifacts, + edaFindings, metricPoints, chartConfig, logEvents,