From 808dc2fd846804130d89cd7977ad6ebc66aad653 Mon Sep 17 00:00:00 2001 From: Lucas Tonon Date: Sun, 19 Jul 2026 08:57:26 -0300 Subject: [PATCH 1/2] feat: turn EDA findings into one-click-to-prep action cards (#111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - New `report-eda-findings` capability skill: the eda agent publishes its report.md findings ALSO as structured items — finding type (leakage / class_imbalance / high_cardinality / multicollinearity / missing_values / outliers / duplicates / skewed_target / id_column / constant_column / datetime_leakage / other), affected columns, severity, summary, and a recommendation phrased as a verbatim data_prep instruction. Handler normalizes (unknown type/severity coerced, scalar columns wrapped, 50-finding cap) and publishes one persisted `eda_findings` event per batch. - eda.yaml: skill declared + a task-list step 7 instructing the agent to call it once right after report.md, mirroring the prose findings. No other agent gains the skill — default behavior elsewhere unchanged. Frontend: - New Findings canvas tab (WorkspaceCanvas) rendering EdaFindingsPanel: severity-sorted cards with type badge, column chips, summary, recommendation, and an "Apply in prep" button. - "Apply in prep" pre-fills the chat input with "In data prep, address the EDA finding [type] on `cols`: " — multiple clicks stack line-by-line so one prompt can carry several prep actions. - useSessionStream: `eda_findings` reducer case + restore-on-reload hydration, deduped on (type + columns + summary). Tests: 7 new (skill discovery + schema, eda-only skill declaration with an explicit other-agents-unchanged check, normalization, malformed-field coercion, empty/invalid batches error without publishing, batch cap). Closes #111 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4 --- backend/agents/eda.yaml | 11 ++ backend/skills/report-eda-findings/SKILL.md | 40 +++++ backend/skills/report-eda-findings/handler.py | 154 +++++++++++++++++ .../skills/report-eda-findings/schema.yaml | 48 ++++++ backend/tests/test_eda_findings.py | 156 ++++++++++++++++++ frontend/src/app/page.tsx | 25 ++- .../components/workspace/EdaFindingsPanel.tsx | 151 +++++++++++++++++ .../components/workspace/WorkspaceCanvas.tsx | 36 +++- frontend/src/lib/types.ts | 28 ++++ frontend/src/lib/useSessionStream.ts | 57 ++++++- 10 files changed, 702 insertions(+), 4 deletions(-) create mode 100644 backend/skills/report-eda-findings/SKILL.md create mode 100644 backend/skills/report-eda-findings/handler.py create mode 100644 backend/skills/report-eda-findings/schema.yaml create mode 100644 backend/tests/test_eda_findings.py create mode 100644 frontend/src/components/workspace/EdaFindingsPanel.tsx 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..4203a71 --- /dev/null +++ b/backend/skills/report-eda-findings/handler.py @@ -0,0 +1,154 @@ +"""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) + dropped += 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, + ) + + note = f" ({dropped} invalid item(s) dropped)" if dropped 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..0ef8c14 --- /dev/null +++ b/backend/tests/test_eda_findings.py @@ -0,0 +1,156 @@ +"""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 invalid item(s) dropped)" in result["content"][0]["text"] diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index fe91211..a63b895 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -4,8 +4,13 @@ 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 +118,7 @@ function HomePageContent() { generatedFiles, fileTree, htmlArtifacts, + edaFindings, metricPoints, chartConfig, logEvents, @@ -238,6 +244,19 @@ 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. @@ -743,6 +762,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..8204650 --- /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 default 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, i) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/workspace/WorkspaceCanvas.tsx b/frontend/src/components/workspace/WorkspaceCanvas.tsx index b7f7239..e86237c 100644 --- a/frontend/src/components/workspace/WorkspaceCanvas.tsx +++ b/frontend/src/components/workspace/WorkspaceCanvas.tsx @@ -9,11 +9,13 @@ import { FolderOpen, GitBranch, Globe, + ListChecks, X, } from 'lucide-react'; import { api } from '@/lib/api'; import type { ChartConfig, + EdaFinding, FileTreeNode, GeneratedFile, HtmlArtifact, @@ -28,6 +30,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'; @@ -42,7 +45,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; } @@ -50,6 +53,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({ @@ -63,6 +67,8 @@ export default function WorkspaceCanvas({ chartConfig, logEvents, htmlArtifacts, + edaFindings, + onApplyInPrep, sessionState, onClose, }: { @@ -76,6 +82,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; }) { @@ -285,6 +295,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(() => { @@ -643,6 +675,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 2574368..4a4ede5 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -362,6 +362,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; @@ -412,6 +439,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 86355aa..3718872 100644 --- a/frontend/src/lib/useSessionStream.ts +++ b/frontend/src/lib/useSessionStream.ts @@ -15,6 +15,7 @@ import type { Task, GeneratedFile, UsageEvent, + EdaFinding, } from '@/lib/types'; import { type ChatItem, metaStr, metaNum } from '@/lib/chatItems'; import type { ActiveAgent } from '@/components/AgentStatusIndicator'; @@ -54,6 +55,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; @@ -128,6 +131,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 — @@ -166,6 +174,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 // --------------------------------------------------------------------------- @@ -720,6 +759,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': { @@ -866,7 +910,7 @@ export function useSessionStream( source.onerror = () => setSseConnected(false); sseRef.current = source; }, - [addItem, openCanvas, publish, refreshExperiments, setIsRunning], + [addItem, appendEdaFindings, openCanvas, publish, refreshExperiments, setIsRunning], ); // --------------------------------------------------------------------------- @@ -896,6 +940,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 @@ -978,6 +1024,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. @@ -1176,6 +1223,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: @@ -1284,6 +1336,7 @@ export function useSessionStream( } setChatItems(restored); + appendEdaFindings(restoredEdaFindings); setCanvasContent(restoredCanvasContent); setCanvasTitle(restoredCanvasTitle); if (restoredHtmlArtifacts.size > 0) { @@ -1376,6 +1429,7 @@ export function useSessionStream( activeSessionId, connectSSE, addItem, + appendEdaFindings, resetSessionState, openCanvas, setIsRunning, @@ -1395,6 +1449,7 @@ export function useSessionStream( generatedFiles, fileTree, htmlArtifacts, + edaFindings, metricPoints, chartConfig, logEvents, From 4275527c41421b5bb6998d81b0b5636c7be0a4ff Mon Sep 17 00:00:00 2001 From: Lucas Tonon Date: Wed, 29 Jul 2026 16:23:29 -0300 Subject: [PATCH 2/2] fix(review): address Greptile findings on #172 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EdaFindingsPanel: stable content-derived card key (was the sorted-array index, which shifts when new findings arrive — resetting each card's applied state and allowing duplicate "Apply in prep" appends). - EdaFindingsPanel: named export per components/ style guide. - report-eda-findings handler: report items truncated by the 50-finding cap separately from schema-invalid drops, so the agent doesn't mistake the cap for a formatting problem and retry. Co-Authored-By: Claude Opus 4.8 --- backend/skills/report-eda-findings/handler.py | 14 ++++++++++++-- backend/tests/test_eda_findings.py | 13 +++++++++++-- .../src/components/workspace/EdaFindingsPanel.tsx | 6 +++--- .../src/components/workspace/WorkspaceCanvas.tsx | 2 +- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/backend/skills/report-eda-findings/handler.py b/backend/skills/report-eda-findings/handler.py index 4203a71..c3d2db2 100644 --- a/backend/skills/report-eda-findings/handler.py +++ b/backend/skills/report-eda-findings/handler.py @@ -108,7 +108,10 @@ async def handler(args: dict): dropped += 1 continue findings.append(norm) - dropped += max(0, len(raw) - _MAX_FINDINGS) + # 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 { @@ -137,7 +140,14 @@ async def handler(args: dict): agent_meta=agent_meta, ) - note = f" ({dropped} invalid item(s) dropped)" if dropped else "" + 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": [ { diff --git a/backend/tests/test_eda_findings.py b/backend/tests/test_eda_findings.py index 0ef8c14..d5a1f0a 100644 --- a/backend/tests/test_eda_findings.py +++ b/backend/tests/test_eda_findings.py @@ -50,7 +50,14 @@ def test_eda_agent_declares_the_skill(): 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"): + for agent in ( + "chat", + "orchestrator", + "data_prep", + "feature_eng", + "trainer", + "reviewer", + ): assert SLUG not in get_agent_skills(agent), agent @@ -153,4 +160,6 @@ async def test_handler_caps_batch_size(): result = await handler({"findings": findings}) assert not result.get("is_error") assert recorder.events[0]["data"]["count"] == 50 - assert "(10 invalid item(s) dropped)" in result["content"][0]["text"] + assert ( + "(10 item(s) omitted over the 50-finding cap)" in result["content"][0]["text"] + ) diff --git a/frontend/src/components/workspace/EdaFindingsPanel.tsx b/frontend/src/components/workspace/EdaFindingsPanel.tsx index 8204650..e373941 100644 --- a/frontend/src/components/workspace/EdaFindingsPanel.tsx +++ b/frontend/src/components/workspace/EdaFindingsPanel.tsx @@ -118,7 +118,7 @@ function FindingCard({ ); } -export default function EdaFindingsPanel({ +export function EdaFindingsPanel({ findings, onApplyInPrep, }: { @@ -138,9 +138,9 @@ export default function EdaFindingsPanel({ data-prep instruction — review it, then send.
- {sorted.map((f, i) => ( + {sorted.map((f) => ( diff --git a/frontend/src/components/workspace/WorkspaceCanvas.tsx b/frontend/src/components/workspace/WorkspaceCanvas.tsx index e86237c..019d740 100644 --- a/frontend/src/components/workspace/WorkspaceCanvas.tsx +++ b/frontend/src/components/workspace/WorkspaceCanvas.tsx @@ -30,7 +30,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 { EdaFindingsPanel } from '@/components/workspace/EdaFindingsPanel'; import MetricsPanel from '@/components/workspace/MetricsPanel'; import ReportMarkdown from '@/components/workspace/ReportMarkdown'; import { getFileIconInfo } from '@/components/workspace/fileIcons';