dataeval-flow 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dataeval_flow/__init__.py +93 -0
- dataeval_flow/__main__.py +149 -0
- dataeval_flow/_app/__init__.py +5 -0
- dataeval_flow/_app/_model/__init__.py +5 -0
- dataeval_flow/_app/_model/_coerce.py +126 -0
- dataeval_flow/_app/_model/_discover.py +171 -0
- dataeval_flow/_app/_model/_execution.py +108 -0
- dataeval_flow/_app/_model/_introspect.py +280 -0
- dataeval_flow/_app/_model/_item.py +213 -0
- dataeval_flow/_app/_model/_registry.py +255 -0
- dataeval_flow/_app/_model/_state.py +322 -0
- dataeval_flow/_app/_model/_undo.py +61 -0
- dataeval_flow/_app/_panes/__init__.py +35 -0
- dataeval_flow/_app/_panes/_config_pane.py +173 -0
- dataeval_flow/_app/_panes/_result_pane.py +125 -0
- dataeval_flow/_app/_panes/_task_pane.py +91 -0
- dataeval_flow/_app/_panes/_widgets.py +111 -0
- dataeval_flow/_app/_screens/__init__.py +25 -0
- dataeval_flow/_app/_screens/_base.py +242 -0
- dataeval_flow/_app/_screens/_detail.py +333 -0
- dataeval_flow/_app/_screens/_model.py +102 -0
- dataeval_flow/_app/_screens/_params.py +80 -0
- dataeval_flow/_app/_screens/_pathpicker.py +68 -0
- dataeval_flow/_app/_screens/_section.py +621 -0
- dataeval_flow/_app/_screens/_settings.py +183 -0
- dataeval_flow/_app/_viewmodel/__init__.py +15 -0
- dataeval_flow/_app/_viewmodel/_builder_vm.py +272 -0
- dataeval_flow/_app/_viewmodel/_model_vm.py +70 -0
- dataeval_flow/_app/_viewmodel/_rendering.py +189 -0
- dataeval_flow/_app/_viewmodel/_result_vm.py +210 -0
- dataeval_flow/_app/_viewmodel/_section_vm.py +224 -0
- dataeval_flow/_app/app.py +742 -0
- dataeval_flow/_app/cli.py +592 -0
- dataeval_flow/_logging.py +102 -0
- dataeval_flow/cache.py +1355 -0
- dataeval_flow/config/__init__.py +80 -0
- dataeval_flow/config/_loader.py +79 -0
- dataeval_flow/config/_merge.py +92 -0
- dataeval_flow/config/_models.py +115 -0
- dataeval_flow/config/_paths.py +85 -0
- dataeval_flow/config/schemas/__init__.py +112 -0
- dataeval_flow/config/schemas/_dataset.py +111 -0
- dataeval_flow/config/schemas/_extractor.py +119 -0
- dataeval_flow/config/schemas/_metadata.py +28 -0
- dataeval_flow/config/schemas/_preprocessor.py +18 -0
- dataeval_flow/config/schemas/_selection.py +100 -0
- dataeval_flow/config/schemas/_task.py +89 -0
- dataeval_flow/config/schemas/_workflow.py +135 -0
- dataeval_flow/dataset.py +635 -0
- dataeval_flow/embeddings.py +135 -0
- dataeval_flow/metadata.py +48 -0
- dataeval_flow/preprocessing.py +141 -0
- dataeval_flow/py.typed +0 -0
- dataeval_flow/runner.py +118 -0
- dataeval_flow/selection.py +50 -0
- dataeval_flow/workflow/__init__.py +328 -0
- dataeval_flow/workflow/_text_report.py +511 -0
- dataeval_flow/workflow/base.py +69 -0
- dataeval_flow/workflow/orchestrator.py +454 -0
- dataeval_flow/workflows/__init__.py +1 -0
- dataeval_flow/workflows/analysis/__init__.py +38 -0
- dataeval_flow/workflows/analysis/outputs.py +202 -0
- dataeval_flow/workflows/analysis/params.py +114 -0
- dataeval_flow/workflows/analysis/workflow.py +1313 -0
- dataeval_flow/workflows/cleaning/__init__.py +23 -0
- dataeval_flow/workflows/cleaning/outputs.py +200 -0
- dataeval_flow/workflows/cleaning/params.py +160 -0
- dataeval_flow/workflows/cleaning/report.py +304 -0
- dataeval_flow/workflows/cleaning/workflow.py +794 -0
- dataeval_flow/workflows/drift/__init__.py +1 -0
- dataeval_flow/workflows/drift/outputs.py +144 -0
- dataeval_flow/workflows/drift/params.py +332 -0
- dataeval_flow/workflows/drift/report.py +201 -0
- dataeval_flow/workflows/drift/workflow.py +647 -0
- dataeval_flow/workflows/ood/__init__.py +1 -0
- dataeval_flow/workflows/ood/outputs.py +134 -0
- dataeval_flow/workflows/ood/params.py +161 -0
- dataeval_flow/workflows/ood/report.py +311 -0
- dataeval_flow/workflows/ood/workflow.py +728 -0
- dataeval_flow/workflows/prioritization/__init__.py +1 -0
- dataeval_flow/workflows/prioritization/outputs.py +122 -0
- dataeval_flow/workflows/prioritization/params.py +124 -0
- dataeval_flow/workflows/prioritization/report.py +117 -0
- dataeval_flow/workflows/prioritization/workflow.py +587 -0
- dataeval_flow/workflows/splitting/__init__.py +25 -0
- dataeval_flow/workflows/splitting/outputs.py +101 -0
- dataeval_flow/workflows/splitting/params.py +61 -0
- dataeval_flow/workflows/splitting/report.py +485 -0
- dataeval_flow/workflows/splitting/workflow.py +371 -0
- dataeval_flow-0.1.0.dist-info/METADATA +305 -0
- dataeval_flow-0.1.0.dist-info/RECORD +94 -0
- dataeval_flow-0.1.0.dist-info/WHEEL +4 -0
- dataeval_flow-0.1.0.dist-info/entry_points.txt +2 -0
- dataeval_flow-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Item snippet rendering for the configuration builder.
|
|
2
|
+
|
|
3
|
+
Presentation-layer functions that produce Rich-markup snippets for display
|
|
4
|
+
in the TUI cards. These live in the ViewModel layer because they transform
|
|
5
|
+
Model data into view-ready strings. No Textual widget dependency.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from dataeval_flow._app._model._execution import TaskExecution
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Individual snippet renderers
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _snippet_steps(item: dict[str, Any], key: str) -> str:
|
|
23
|
+
lines = [f"[bold]{item.get('name', '?')}[/bold]"]
|
|
24
|
+
for s in item.get("steps", []):
|
|
25
|
+
label = s.get(key, "?")
|
|
26
|
+
params = s.get("params", {})
|
|
27
|
+
if params:
|
|
28
|
+
p_str = ", ".join(f"{k}={v}" for k, v in params.items())
|
|
29
|
+
lines.append(f" {label}({p_str})")
|
|
30
|
+
else:
|
|
31
|
+
lines.append(f" {label}")
|
|
32
|
+
return "\n".join(lines)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _snippet_dataset(item: dict[str, Any]) -> str:
|
|
36
|
+
name = item.get("name", "?")
|
|
37
|
+
fmt = item.get("format", "?")
|
|
38
|
+
path = item.get("path", "?")
|
|
39
|
+
split = item.get("split", "")
|
|
40
|
+
lines = [f"[bold]{name}[/bold]", f" format: {fmt} path: {path}"]
|
|
41
|
+
if split:
|
|
42
|
+
lines.append(f" split: {split}")
|
|
43
|
+
return "\n".join(lines)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _snippet_source(item: dict[str, Any]) -> str:
|
|
47
|
+
name = item.get("name", "?")
|
|
48
|
+
dataset = item.get("dataset", "?")
|
|
49
|
+
selection = item.get("selection", "")
|
|
50
|
+
lines = [f"[bold]{name}[/bold]", f" dataset: {dataset}"]
|
|
51
|
+
if selection:
|
|
52
|
+
lines.append(f" selection: {selection}")
|
|
53
|
+
return "\n".join(lines)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _snippet_extractor(item: dict[str, Any]) -> str:
|
|
57
|
+
name = item.get("name", "?")
|
|
58
|
+
model_type = item.get("model", "?")
|
|
59
|
+
lines = [f"[bold]{name}[/bold] [dim]{model_type}[/dim]"]
|
|
60
|
+
if item.get("model_path"):
|
|
61
|
+
lines.append(f" path: {item['model_path']}")
|
|
62
|
+
if model_type == "bovw" and item.get("vocab_size"):
|
|
63
|
+
lines.append(f" vocab_size: {item['vocab_size']}")
|
|
64
|
+
pre = item.get("preprocessor", "")
|
|
65
|
+
if pre:
|
|
66
|
+
lines.append(f" preprocessor: {pre}")
|
|
67
|
+
batch = item.get("batch_size", "")
|
|
68
|
+
if batch:
|
|
69
|
+
lines.append(f" batch_size: {batch}")
|
|
70
|
+
return "\n".join(lines)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _snippet_workflow(item: dict[str, Any]) -> str:
|
|
74
|
+
name = item.get("name", "?")
|
|
75
|
+
wf_type = item.get("type", "?")
|
|
76
|
+
lines = [f"[bold]{name}[/bold] [dim]{wf_type}[/dim]"]
|
|
77
|
+
extras = [f" {k}: {v}" for k, v in item.items() if k not in ("name", "type") and v]
|
|
78
|
+
lines.extend(extras)
|
|
79
|
+
return "\n".join(lines)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _snippet_task(item: dict[str, Any]) -> str:
|
|
83
|
+
enabled = item.get("enabled", True)
|
|
84
|
+
check = "[bold green]\u2713[/bold green]" if enabled else "[dim]\u2717[/dim]"
|
|
85
|
+
name = item.get("name", "?")
|
|
86
|
+
wf = item.get("workflow", "?")
|
|
87
|
+
srcs = item.get("sources", "")
|
|
88
|
+
if isinstance(srcs, list):
|
|
89
|
+
srcs = ", ".join(srcs)
|
|
90
|
+
lines = [f"{check} [bold]{name}[/bold]"]
|
|
91
|
+
lines.append(f" workflow: {wf} sources: {srcs}")
|
|
92
|
+
extractor = item.get("extractor", "")
|
|
93
|
+
if extractor:
|
|
94
|
+
lines.append(f" extractor: {extractor}")
|
|
95
|
+
text = "\n".join(lines)
|
|
96
|
+
if not enabled:
|
|
97
|
+
text = f"{check} [dim strikethrough][bold]{name}[/bold]\n workflow: {wf} sources: {srcs}"
|
|
98
|
+
if extractor:
|
|
99
|
+
text += f"\n extractor: {extractor}"
|
|
100
|
+
text += "[/dim strikethrough]"
|
|
101
|
+
return text
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Dispatch table and entry point
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
_SNIPPET_RENDERERS: dict[str, Any] = {
|
|
109
|
+
"datasets": _snippet_dataset,
|
|
110
|
+
"preprocessors": lambda item: _snippet_steps(item, "step"),
|
|
111
|
+
"selections": lambda item: _snippet_steps(item, "type"),
|
|
112
|
+
"sources": _snippet_source,
|
|
113
|
+
"extractors": _snippet_extractor,
|
|
114
|
+
"workflows": _snippet_workflow,
|
|
115
|
+
"tasks": _snippet_task,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _item_to_yaml_snippet(category: str, item: dict[str, Any]) -> str:
|
|
120
|
+
renderer = _SNIPPET_RENDERERS.get(category)
|
|
121
|
+
if renderer:
|
|
122
|
+
return renderer(item)
|
|
123
|
+
return yaml.dump(item, default_flow_style=True).strip()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Execution-aware task snippet (for dashboard task pane)
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
_STATUS_INDICATORS: dict[str, str] = {
|
|
131
|
+
"idle": "[dim]\u25cf[/dim]",
|
|
132
|
+
"running": "[bold yellow]\u25d0[/bold yellow] [yellow]running...[/yellow]",
|
|
133
|
+
"completed": "[bold green]\u2713[/bold green]",
|
|
134
|
+
"failed": "[bold red]\u2717[/bold red] [red]failed[/red]",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def snippet_task_with_execution(task: dict[str, Any], execution: TaskExecution | None = None) -> str:
|
|
139
|
+
"""Render a task card snippet with execution status indicator.
|
|
140
|
+
|
|
141
|
+
Used in the dashboard task pane where status is shown inline.
|
|
142
|
+
"""
|
|
143
|
+
enabled = task.get("enabled", True)
|
|
144
|
+
check = "[bold green]\u2713[/bold green]" if enabled else "[dim]\u2717[/dim]"
|
|
145
|
+
name = task.get("name", "?")
|
|
146
|
+
wf = task.get("workflow", "?")
|
|
147
|
+
srcs = task.get("sources", "")
|
|
148
|
+
if isinstance(srcs, list):
|
|
149
|
+
srcs = ", ".join(srcs)
|
|
150
|
+
|
|
151
|
+
# Status indicator
|
|
152
|
+
if execution is not None:
|
|
153
|
+
status = _STATUS_INDICATORS.get(execution.status, _STATUS_INDICATORS["idle"])
|
|
154
|
+
if execution.status == "completed" and execution.elapsed_s is not None:
|
|
155
|
+
status += f" [dim]{execution.elapsed_s:.1f}s[/dim]"
|
|
156
|
+
else:
|
|
157
|
+
status = _STATUS_INDICATORS["idle"]
|
|
158
|
+
|
|
159
|
+
# Build the snippet
|
|
160
|
+
line1 = f"{check} [bold]{name}[/bold] {status}"
|
|
161
|
+
line2 = f" {wf} > {srcs}"
|
|
162
|
+
extractor = task.get("extractor", "")
|
|
163
|
+
if extractor:
|
|
164
|
+
line2 += f" [dim]({extractor})[/dim]"
|
|
165
|
+
|
|
166
|
+
if not enabled:
|
|
167
|
+
return f"{check} [dim strikethrough]{name} {wf} > {srcs}[/dim strikethrough] {status}"
|
|
168
|
+
return f"{line1}\n{line2}"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def snippet_config_item(category: str, item: dict[str, Any]) -> str:
|
|
172
|
+
"""Compact one-line snippet for config sidebar items."""
|
|
173
|
+
name = item.get("name", "?")
|
|
174
|
+
if category == "datasets":
|
|
175
|
+
fmt = item.get("format", "")
|
|
176
|
+
return f"[bold]{name}[/bold] [dim]{fmt}[/dim]"
|
|
177
|
+
if category == "extractors":
|
|
178
|
+
model = item.get("model", "")
|
|
179
|
+
return f"[bold]{name}[/bold] [dim]{model}[/dim]"
|
|
180
|
+
if category == "workflows":
|
|
181
|
+
wf_type = item.get("type", "")
|
|
182
|
+
return f"[bold]{name}[/bold] [dim]{wf_type}[/dim]"
|
|
183
|
+
if category == "sources":
|
|
184
|
+
dataset = item.get("dataset", "")
|
|
185
|
+
return f"[bold]{name}[/bold] [dim]({dataset})[/dim]"
|
|
186
|
+
if category in ("preprocessors", "selections"):
|
|
187
|
+
n_steps = len(item.get("steps", []))
|
|
188
|
+
return f"[bold]{name}[/bold] [dim]{n_steps} step{'s' if n_steps != 1 else ''}[/dim]"
|
|
189
|
+
return f"[bold]{name}[/bold]"
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""ViewModel for rendering workflow results in the TUI.
|
|
2
|
+
|
|
3
|
+
Transforms ``WorkflowResult`` data into view-ready structures.
|
|
4
|
+
No Textual dependency — consumed by the result modal and result cards.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from dataeval_flow.workflow._text_report import (
|
|
13
|
+
_brief_value,
|
|
14
|
+
_render_detail_section,
|
|
15
|
+
_summary_line,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = ["FindingSummary", "ResultViewModel"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class FindingSummary:
|
|
23
|
+
"""View-ready summary of a single report finding."""
|
|
24
|
+
|
|
25
|
+
title: str
|
|
26
|
+
severity: str # "ok" | "info" | "warning"
|
|
27
|
+
brief: str
|
|
28
|
+
report_type: str
|
|
29
|
+
has_table: bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ResultViewModel:
|
|
33
|
+
"""Transforms a ``WorkflowResult`` into view-ready structures."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, result: Any) -> None:
|
|
36
|
+
self._result = result
|
|
37
|
+
self._findings = self._extract_findings()
|
|
38
|
+
|
|
39
|
+
def _extract_findings(self) -> list[Any]:
|
|
40
|
+
report_obj = getattr(self._result.data, "report", None)
|
|
41
|
+
if report_obj is None:
|
|
42
|
+
return []
|
|
43
|
+
return list(getattr(report_obj, "findings", []))
|
|
44
|
+
|
|
45
|
+
# -- Summary -----------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def summary_line(self) -> str:
|
|
48
|
+
"""One-line summary: finding count, warning count, duration."""
|
|
49
|
+
findings = self._findings
|
|
50
|
+
n = len(findings)
|
|
51
|
+
warnings = sum(1 for f in findings if getattr(f, "severity", "info") == "warning")
|
|
52
|
+
parts: list[str] = []
|
|
53
|
+
parts.append(f"{n} finding{'s' if n != 1 else ''}")
|
|
54
|
+
if warnings:
|
|
55
|
+
parts.append(f"{warnings} warning{'s' if warnings != 1 else ''}")
|
|
56
|
+
meta = self._result.metadata
|
|
57
|
+
if meta.execution_time_s is not None:
|
|
58
|
+
parts.append(f"{meta.execution_time_s:.1f}s")
|
|
59
|
+
return ", ".join(parts)
|
|
60
|
+
|
|
61
|
+
def report_summary(self) -> str:
|
|
62
|
+
"""The workflow's own summary string (e.g. 'Data Cleaning Report')."""
|
|
63
|
+
report_obj = getattr(self._result.data, "report", None)
|
|
64
|
+
if report_obj is None:
|
|
65
|
+
return ""
|
|
66
|
+
return getattr(report_obj, "summary", "")
|
|
67
|
+
|
|
68
|
+
# -- Metadata ----------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def metadata_lines(self) -> list[str]:
|
|
71
|
+
"""Human-readable metadata lines (timestamp, duration, source, model)."""
|
|
72
|
+
meta = self._result.metadata
|
|
73
|
+
lines: list[str] = []
|
|
74
|
+
if meta.timestamp:
|
|
75
|
+
lines.append(f"Timestamp: {meta.timestamp.isoformat()}")
|
|
76
|
+
if meta.execution_time_s is not None:
|
|
77
|
+
lines.append(f"Duration: {meta.execution_time_s:.2f}s")
|
|
78
|
+
lines.extend(f"Source: {desc}" for desc in getattr(meta, "source_descriptions", []))
|
|
79
|
+
if meta.model_id:
|
|
80
|
+
lines.append(f"Model: {meta.model_id}")
|
|
81
|
+
if meta.preprocessor_id:
|
|
82
|
+
lines.append(f"Preprocessor: {meta.preprocessor_id}")
|
|
83
|
+
return lines
|
|
84
|
+
|
|
85
|
+
# -- Findings ----------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def finding_count(self) -> int:
|
|
88
|
+
"""Number of findings."""
|
|
89
|
+
return len(self._findings)
|
|
90
|
+
|
|
91
|
+
def warning_count(self) -> int:
|
|
92
|
+
"""Number of findings with severity 'warning'."""
|
|
93
|
+
return sum(1 for f in self._findings if getattr(f, "severity", "info") == "warning")
|
|
94
|
+
|
|
95
|
+
def finding_summaries(self) -> list[FindingSummary]:
|
|
96
|
+
"""Return view-ready summaries for all findings."""
|
|
97
|
+
summaries: list[FindingSummary] = []
|
|
98
|
+
for finding in self._findings:
|
|
99
|
+
rt = finding.report_type
|
|
100
|
+
summaries.append(
|
|
101
|
+
FindingSummary(
|
|
102
|
+
title=finding.title,
|
|
103
|
+
severity=getattr(finding, "severity", "info"),
|
|
104
|
+
brief=_brief_value(finding),
|
|
105
|
+
report_type=rt,
|
|
106
|
+
has_table=rt in ("table", "pivot_table", "classwise_table", "chunk_table"),
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
return summaries
|
|
110
|
+
|
|
111
|
+
def finding_summary_markup(self, idx: int) -> str:
|
|
112
|
+
"""Rich-markup one-liner for finding at *idx* (dotted summary style)."""
|
|
113
|
+
if 0 <= idx < len(self._findings):
|
|
114
|
+
return _summary_line(self._findings[idx])
|
|
115
|
+
return ""
|
|
116
|
+
|
|
117
|
+
def finding_detail_markup(self, idx: int) -> str:
|
|
118
|
+
"""Rich-markup detail block for finding at *idx*."""
|
|
119
|
+
if 0 <= idx < len(self._findings):
|
|
120
|
+
lines = _render_detail_section(self._findings[idx])
|
|
121
|
+
return "\n".join(lines)
|
|
122
|
+
return ""
|
|
123
|
+
|
|
124
|
+
def finding_table_data(self, idx: int) -> tuple[list[str], list[list[str]]] | None:
|
|
125
|
+
"""Extract structured table data for ``DataTable`` rendering.
|
|
126
|
+
|
|
127
|
+
Returns ``(headers, rows)`` where each row is a list of strings,
|
|
128
|
+
or ``None`` if the finding doesn't have tabular data.
|
|
129
|
+
"""
|
|
130
|
+
if not (0 <= idx < len(self._findings)):
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
finding = self._findings[idx]
|
|
134
|
+
data = finding.data
|
|
135
|
+
if not isinstance(data, dict):
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
rt = finding.report_type
|
|
139
|
+
|
|
140
|
+
if rt == "table":
|
|
141
|
+
return self._extract_simple_table(data)
|
|
142
|
+
if rt == "pivot_table":
|
|
143
|
+
return self._extract_pivot_table(data)
|
|
144
|
+
if rt in ("classwise_table", "chunk_table"):
|
|
145
|
+
return self._extract_row_table(data)
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
# -- Health summary ----------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def health_line(self) -> str:
|
|
151
|
+
"""Health status string for the summary section."""
|
|
152
|
+
warnings = self.warning_count()
|
|
153
|
+
if warnings:
|
|
154
|
+
return f"Health: {warnings} warning(s) — review flagged findings"
|
|
155
|
+
return "Health: All checks passed"
|
|
156
|
+
|
|
157
|
+
# -- Table extraction helpers ------------------------------------------
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _extract_simple_table(data: dict[str, Any]) -> tuple[list[str], list[list[str]]] | None:
|
|
161
|
+
"""Extract from ``table`` report type (dict of name→count)."""
|
|
162
|
+
table_data: dict[str, int] = data.get("table_data", {})
|
|
163
|
+
if not table_data:
|
|
164
|
+
return None
|
|
165
|
+
headers_raw = data.get("table_headers", ("Name", "Value"))
|
|
166
|
+
headers: list[str] = [str(h) for h in headers_raw]
|
|
167
|
+
rows = [[str(k), str(v)] for k, v in sorted(table_data.items(), key=lambda x: -x[1])]
|
|
168
|
+
return headers, rows
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _extract_pivot_table(data: dict[str, Any]) -> tuple[list[str], list[list[str]]] | None:
|
|
172
|
+
"""Extract from ``pivot_table`` report type."""
|
|
173
|
+
rows_data: list[dict[str, Any]] = data.get("table_data", [])
|
|
174
|
+
headers: list[str] = data.get("table_headers", [])
|
|
175
|
+
if not rows_data or not headers:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
key_aliases: dict[str, str] = {"%": "pct", "Class Name": "class_name", "Count": "count"}
|
|
179
|
+
keys = [key_aliases.get(h, h) for h in headers]
|
|
180
|
+
|
|
181
|
+
rows: list[list[str]] = []
|
|
182
|
+
for row in rows_data:
|
|
183
|
+
cells: list[str] = []
|
|
184
|
+
for key in keys:
|
|
185
|
+
val = row.get(key, "")
|
|
186
|
+
if key == "pct" and isinstance(val, (int, float)):
|
|
187
|
+
cells.append(f"{val:.1f}%")
|
|
188
|
+
else:
|
|
189
|
+
cells.append(str(val) if val is not None else "")
|
|
190
|
+
rows.append(cells)
|
|
191
|
+
return headers, rows
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def _extract_row_table(data: dict[str, Any]) -> tuple[list[str], list[list[str]]] | None:
|
|
195
|
+
"""Extract from ``classwise_table`` or ``chunk_table`` (list of row dicts)."""
|
|
196
|
+
rows_data: list[dict[str, Any]] = data.get("table_rows", [])
|
|
197
|
+
if not rows_data:
|
|
198
|
+
return None
|
|
199
|
+
headers = list(rows_data[0].keys())
|
|
200
|
+
rows: list[list[str]] = []
|
|
201
|
+
for row in rows_data:
|
|
202
|
+
cells: list[str] = []
|
|
203
|
+
for h in headers:
|
|
204
|
+
val = row.get(h, "")
|
|
205
|
+
if isinstance(val, float):
|
|
206
|
+
cells.append(f"{val:.4f}")
|
|
207
|
+
else:
|
|
208
|
+
cells.append(str(val) if val is not None else "")
|
|
209
|
+
rows.append(cells)
|
|
210
|
+
return headers, rows
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""ViewModel for the SectionModal.
|
|
2
|
+
|
|
3
|
+
Manages field descriptors, step-builder state, union-list state,
|
|
4
|
+
and item collection/validation. No UI dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from dataeval_flow._app._model._introspect import FieldDescriptor, FieldKind, introspect_model
|
|
12
|
+
from dataeval_flow._app._model._item import (
|
|
13
|
+
SKIP,
|
|
14
|
+
build_item_dict,
|
|
15
|
+
collect_bool_value,
|
|
16
|
+
collect_field_value,
|
|
17
|
+
collect_json_value,
|
|
18
|
+
collect_multi_select_value,
|
|
19
|
+
diagnose_collect_failure,
|
|
20
|
+
)
|
|
21
|
+
from dataeval_flow._app._model._registry import (
|
|
22
|
+
STEP_BUILDER_SECTIONS,
|
|
23
|
+
get_discriminator_field,
|
|
24
|
+
get_fields,
|
|
25
|
+
get_variant_choices,
|
|
26
|
+
)
|
|
27
|
+
from dataeval_flow._app._model._state import ConfigState
|
|
28
|
+
|
|
29
|
+
__all__ = ["SectionViewModel"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SectionViewModel:
|
|
33
|
+
"""ViewModel for creating/editing items in any config section."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
section: str,
|
|
38
|
+
existing: dict[str, Any] | None = None,
|
|
39
|
+
state: ConfigState | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.section = section
|
|
42
|
+
self.state = state or ConfigState()
|
|
43
|
+
self.existing = existing
|
|
44
|
+
self.original: dict[str, Any] | None = dict(existing) if existing else None
|
|
45
|
+
self.descriptors: list[FieldDescriptor] = []
|
|
46
|
+
self.steps: list[dict[str, Any]] = []
|
|
47
|
+
self.list_items: dict[str, list[dict[str, Any]]] = {}
|
|
48
|
+
self._step_choices: list[str] = []
|
|
49
|
+
self._get_step_params: Any = None
|
|
50
|
+
|
|
51
|
+
if existing and section in STEP_BUILDER_SECTIONS:
|
|
52
|
+
self.steps = [dict(s) for s in existing.get("steps", [])]
|
|
53
|
+
|
|
54
|
+
# -- Properties --------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def is_edit_mode(self) -> bool:
|
|
58
|
+
return self.existing is not None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def is_step_builder(self) -> bool:
|
|
62
|
+
return self.section in STEP_BUILDER_SECTIONS
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def variant_choices(self) -> list[str] | None:
|
|
66
|
+
return get_variant_choices(self.section)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def disc_field(self) -> str | None:
|
|
70
|
+
return get_discriminator_field(self.section)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def step_choices(self) -> list[str]:
|
|
74
|
+
return self._step_choices
|
|
75
|
+
|
|
76
|
+
# -- Field descriptors -------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def load_fields(self, variant_value: str | None) -> list[FieldDescriptor]:
|
|
79
|
+
"""Load field descriptors for the given variant. Stores them internally."""
|
|
80
|
+
self.descriptors = get_fields(self.section, variant_value, self.state)
|
|
81
|
+
return self.descriptors
|
|
82
|
+
|
|
83
|
+
# -- Step builder ------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def init_step_builder(self) -> list[str]:
|
|
86
|
+
"""Initialize step builder and return available step choices."""
|
|
87
|
+
spec = STEP_BUILDER_SECTIONS[self.section]
|
|
88
|
+
from dataeval_flow._app._model import _discover
|
|
89
|
+
|
|
90
|
+
self._step_choices = getattr(_discover, spec["list_fn"])()
|
|
91
|
+
self._get_step_params = getattr(_discover, spec["params_fn"])
|
|
92
|
+
return self._step_choices
|
|
93
|
+
|
|
94
|
+
def get_step_params(self, step_name: str) -> list[Any]:
|
|
95
|
+
"""Return parameter info for a step type."""
|
|
96
|
+
if self._get_step_params:
|
|
97
|
+
return self._get_step_params(step_name)
|
|
98
|
+
return []
|
|
99
|
+
|
|
100
|
+
def add_step(self, step_name: str, params: dict[str, Any]) -> str:
|
|
101
|
+
"""Add a step with collected params. Returns notification message."""
|
|
102
|
+
spec = STEP_BUILDER_SECTIONS[self.section]
|
|
103
|
+
step: dict[str, Any] = {spec["step_key"]: step_name}
|
|
104
|
+
if params:
|
|
105
|
+
step["params"] = params
|
|
106
|
+
self.steps.append(step)
|
|
107
|
+
return f"Added step '{step_name}'."
|
|
108
|
+
|
|
109
|
+
def remove_step(self, index: int) -> bool:
|
|
110
|
+
"""Remove step at index. Returns True if removed."""
|
|
111
|
+
if 0 <= index < len(self.steps):
|
|
112
|
+
self.steps.pop(index)
|
|
113
|
+
return True
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
def step_display_lines(self) -> list[str]:
|
|
117
|
+
"""Return formatted display strings for each step."""
|
|
118
|
+
spec = STEP_BUILDER_SECTIONS.get(self.section, {})
|
|
119
|
+
step_key = spec.get("step_key", "step")
|
|
120
|
+
lines: list[str] = []
|
|
121
|
+
for idx, step in enumerate(self.steps):
|
|
122
|
+
name = step.get(step_key, "?")
|
|
123
|
+
params = step.get("params", {})
|
|
124
|
+
if params:
|
|
125
|
+
p_str = ", ".join(f"{k}={v}" for k, v in params.items())
|
|
126
|
+
lines.append(f"{idx + 1}. {name}({p_str})")
|
|
127
|
+
else:
|
|
128
|
+
lines.append(f"{idx + 1}. {name}")
|
|
129
|
+
return lines
|
|
130
|
+
|
|
131
|
+
# -- Union list builder ------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def add_list_item(self, field_name: str, variant_key: str, field_values: dict[str, Any]) -> str:
|
|
134
|
+
"""Add a union-list item. Returns notification message."""
|
|
135
|
+
desc = next((d for d in self.descriptors if d.name == field_name), None)
|
|
136
|
+
if not desc or not desc.discriminator:
|
|
137
|
+
return ""
|
|
138
|
+
item: dict[str, Any] = {desc.discriminator: variant_key}
|
|
139
|
+
item.update(field_values)
|
|
140
|
+
self.list_items.setdefault(field_name, []).append(item)
|
|
141
|
+
return f"Added {variant_key}."
|
|
142
|
+
|
|
143
|
+
def remove_list_item(self, field_name: str, index: int) -> bool:
|
|
144
|
+
"""Remove a union-list item. Returns True if removed."""
|
|
145
|
+
items = self.list_items.get(field_name, [])
|
|
146
|
+
if 0 <= index < len(items):
|
|
147
|
+
items.pop(index)
|
|
148
|
+
return True
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
def get_variant_descriptors(self, field_name: str, variant_key: str) -> list[FieldDescriptor]:
|
|
152
|
+
"""Return field descriptors for a union variant, excluding the discriminator and complex fields."""
|
|
153
|
+
desc = next((d for d in self.descriptors if d.name == field_name), None)
|
|
154
|
+
if not desc or not desc.union_variants or not desc.discriminator:
|
|
155
|
+
return []
|
|
156
|
+
variant_model = desc.union_variants.get(variant_key)
|
|
157
|
+
if not variant_model:
|
|
158
|
+
return []
|
|
159
|
+
return [
|
|
160
|
+
vd
|
|
161
|
+
for vd in introspect_model(variant_model)
|
|
162
|
+
if vd.name != desc.discriminator and vd.kind.value not in ("nested", "list")
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
# -- Collection (pure logic, takes raw values from view) ---------------
|
|
166
|
+
|
|
167
|
+
def collect_field(self, desc: FieldDescriptor, raw_value: Any) -> Any:
|
|
168
|
+
"""Coerce a raw widget value for a single field. Returns SKIP if empty."""
|
|
169
|
+
if desc.kind == FieldKind.SELECT or (desc.kind == FieldKind.NESTED and desc.union_variants):
|
|
170
|
+
return raw_value if raw_value else SKIP
|
|
171
|
+
if desc.kind == FieldKind.MULTI_SELECT:
|
|
172
|
+
return collect_multi_select_value(raw_value or [], self.section, desc.name)
|
|
173
|
+
if desc.kind == FieldKind.BOOL:
|
|
174
|
+
return collect_bool_value(raw_value, desc.default)
|
|
175
|
+
if desc.kind in (FieldKind.INT, FieldKind.FLOAT, FieldKind.STRING):
|
|
176
|
+
return collect_field_value(desc, raw_value or "")
|
|
177
|
+
if desc.kind == FieldKind.LIST and desc.union_variants:
|
|
178
|
+
items = self.list_items.get(desc.name, [])
|
|
179
|
+
return list(items) if items else SKIP
|
|
180
|
+
if desc.kind == FieldKind.NESTED and desc.item_descriptors and isinstance(raw_value, dict):
|
|
181
|
+
coerced: dict[str, Any] = {}
|
|
182
|
+
for sub_desc in desc.item_descriptors:
|
|
183
|
+
sub_raw = raw_value.get(sub_desc.name)
|
|
184
|
+
sub_val = self.collect_field(sub_desc, sub_raw)
|
|
185
|
+
if sub_val is not SKIP:
|
|
186
|
+
coerced[sub_desc.name] = sub_val
|
|
187
|
+
return coerced if coerced else SKIP
|
|
188
|
+
if desc.kind in (FieldKind.LIST, FieldKind.NESTED):
|
|
189
|
+
return collect_json_value(raw_value or "")
|
|
190
|
+
return SKIP
|
|
191
|
+
|
|
192
|
+
def build_result(
|
|
193
|
+
self,
|
|
194
|
+
name: str,
|
|
195
|
+
variant_value: str | None,
|
|
196
|
+
field_values: dict[str, Any],
|
|
197
|
+
) -> dict[str, Any] | None:
|
|
198
|
+
"""Assemble the final item dict from collected values. Returns None if invalid."""
|
|
199
|
+
if not name:
|
|
200
|
+
return None
|
|
201
|
+
if self.disc_field and not variant_value:
|
|
202
|
+
return None
|
|
203
|
+
if self.is_step_builder:
|
|
204
|
+
if not self.steps:
|
|
205
|
+
return None
|
|
206
|
+
return build_item_dict(self.section, name, variant_value, {"steps": list(self.steps)})
|
|
207
|
+
|
|
208
|
+
# Preserve existing enabled state for tasks
|
|
209
|
+
if self.section == "tasks" and self.existing:
|
|
210
|
+
field_values.setdefault("enabled", self.existing.get("enabled", True))
|
|
211
|
+
|
|
212
|
+
return build_item_dict(self.section, name, variant_value, field_values)
|
|
213
|
+
|
|
214
|
+
def check_dirty(self, collected: dict[str, Any] | None) -> bool:
|
|
215
|
+
"""Return True if collected data differs from original."""
|
|
216
|
+
if not collected:
|
|
217
|
+
return False
|
|
218
|
+
if not self.original:
|
|
219
|
+
return True
|
|
220
|
+
return collected != self.original
|
|
221
|
+
|
|
222
|
+
def diagnose_failure(self, name: str, result: dict[str, Any] | None = None) -> str:
|
|
223
|
+
"""Return a human-readable error message when build_result returns None."""
|
|
224
|
+
return diagnose_collect_failure(self.section, name, self.steps, self.descriptors, result)
|