Skip to content

Commit 30e0a0d

Browse files
authored
Merge pull request #8 from omercengiz/feat/richer-cli-reporting
Feat/richer cli reporting
2 parents ecd1382 + 0db5ce9 commit 30e0a0d

4 files changed

Lines changed: 360 additions & 90 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ Then run:
135135
flowsense analyze <dag_id>
136136
```
137137

138+
The CLI report includes a DAG summary and separate tables for task drift,
139+
handoff drift, change points, trends, propagation paths, and diagnostics.
140+
Results are ordered by severity or subject so repeated analyses remain easy to
141+
compare.
142+
138143
## Library API
139144

140145
FlowSense can also be used as a Python library through its supported top-level
@@ -283,7 +288,6 @@ The current implementation should be considered experimental and is not yet inte
283288
Planned areas include:
284289

285290
- DAG-level analysis models
286-
- richer CLI reporting
287291
- broader Airflow compatibility testing
288292

289293
## License

src/flowsense/cli/main.py

Lines changed: 2 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
import typer
66
from rich.console import Console
7-
from rich.table import Table
87

98
from flowsense.application import analyze_dag
9+
from flowsense.cli.report import render_analysis
1010
from flowsense.domain import AnalysisPolicy, MappedTaskAggregation
1111
from flowsense.infrastructure.airflow import AirflowApiError, AirflowClient
1212

@@ -63,91 +63,4 @@ def analyze(
6363
console.print(f"[bold red]Airflow request failed:[/bold red] {exc}")
6464
raise typer.Exit(code=1) from exc
6565

66-
console.print(f"\n[bold]FlowSense Analysis — {analysis.dag_id}[/bold]\n")
67-
68-
table = Table()
69-
70-
table.add_column("Task")
71-
table.add_column("Baseline")
72-
table.add_column("Current")
73-
table.add_column("Deviation")
74-
table.add_column("Z-Score")
75-
table.add_column("Severity")
76-
table.add_column("Impact")
77-
78-
for task_id, result in analysis.drift_results.items():
79-
impact = analysis.task_impacts.get(task_id)
80-
impact_label = impact.classification if impact else "-"
81-
82-
table.add_row(
83-
task_id,
84-
f"{result.baseline:.2f}s",
85-
f"{result.current:.2f}s",
86-
f"{result.deviation_percent:+.1f}%",
87-
f"{result.robust_z_score:.2f}",
88-
result.severity,
89-
impact_label,
90-
)
91-
92-
console.print(table)
93-
94-
console.print(f"\nOverall Severity: [bold]{analysis.overall_severity}[/bold]")
95-
96-
if analysis.change_point_results or analysis.handoff_change_point_results:
97-
console.print("\n[bold]Change Points[/bold]\n")
98-
99-
for result in [
100-
*analysis.change_point_results.values(),
101-
*analysis.handoff_change_point_results.values(),
102-
]:
103-
change = (
104-
f"{result.change_percent:+.1f}%"
105-
if result.change_percent is not None
106-
else "n/a"
107-
)
108-
console.print(
109-
f"{result.subject_id}: {result.direction} at observation "
110-
f"{result.change_index + 1} ({change}, score={result.score:.2f})"
111-
)
112-
113-
if analysis.trend_results or analysis.handoff_trend_results:
114-
console.print("\n[bold]Trends[/bold]\n")
115-
116-
for result in [
117-
*analysis.trend_results.values(),
118-
*analysis.handoff_trend_results.values(),
119-
]:
120-
change = (
121-
f"{result.change_percent:+.1f}%"
122-
if result.change_percent is not None
123-
else "n/a"
124-
)
125-
console.print(
126-
f"{result.subject_id}: {result.direction} "
127-
f"({result.slope_per_observation:+.2f}/run, {change}, "
128-
f"score={result.score:.2f})"
129-
)
130-
131-
if analysis.primary_origin:
132-
console.print(f"Primary Origin: [bold]{analysis.primary_origin.task_id}[/bold]")
133-
console.print(f"Reason: [bold]{analysis.primary_origin.classification}[/bold]")
134-
console.print(f"Severity: [bold]{analysis.primary_origin.severity}[/bold]")
135-
console.print(
136-
f"Propagation Score: {analysis.primary_origin.propagation_score:.2f}"
137-
)
138-
139-
if analysis.propagation_results:
140-
console.print("\n[bold]Propagation Analysis[/bold]\n")
141-
142-
for result in analysis.propagation_results:
143-
console.print(f"Origin: {result.origin_task}")
144-
console.print(f"Path: {' -> '.join(result.path)}")
145-
console.print(f"Propagation Score: {result.propagation_score:.2f}")
146-
147-
if analysis.diagnostics:
148-
console.print("\n[bold yellow]Diagnostics[/bold yellow]\n")
149-
150-
for diagnostic in analysis.diagnostics:
151-
console.print(
152-
f"[{diagnostic.code}] {diagnostic.subject_id}: {diagnostic.message}"
153-
)
66+
render_analysis(console, analysis)

src/flowsense/cli/report.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
from __future__ import annotations
2+
3+
from rich.console import Console
4+
from rich.panel import Panel
5+
from rich.table import Table
6+
from rich.text import Text
7+
8+
from flowsense.domain import DAGAnalysis, Severity
9+
from flowsense.domain.enums import SEVERITY_SCORE
10+
11+
_SEVERITY_STYLES = {
12+
Severity.NORMAL: "green",
13+
Severity.MEDIUM: "yellow",
14+
Severity.HIGH: "bright_red",
15+
Severity.CRITICAL: "bold red",
16+
}
17+
18+
19+
def _severity_text(severity: Severity) -> Text:
20+
return Text(str(severity), style=_SEVERITY_STYLES[severity])
21+
22+
23+
def _percent(value: float | None) -> str:
24+
return f"{value:+.1f}%" if value is not None else "n/a"
25+
26+
27+
def _render_summary(console: Console, analysis: DAGAnalysis) -> None:
28+
summary = Table.grid(padding=(0, 2))
29+
summary.add_column(style="bold")
30+
summary.add_column()
31+
summary.add_row("DAG", analysis.dag_id)
32+
summary.add_row("Runs analyzed", str(analysis.runs_analyzed))
33+
summary.add_row("Overall severity", _severity_text(analysis.overall_severity))
34+
35+
if analysis.primary_origin is not None:
36+
summary.add_row("Primary origin", analysis.primary_origin.task_id)
37+
summary.add_row("Classification", str(analysis.primary_origin.classification))
38+
summary.add_row(
39+
"Propagation score",
40+
f"{analysis.primary_origin.propagation_score:.2f}",
41+
)
42+
43+
console.print(Panel(summary, title="FlowSense Analysis", expand=False))
44+
45+
46+
def _render_task_drift(console: Console, analysis: DAGAnalysis) -> None:
47+
table = Table(title="Task Drift")
48+
table.add_column("Task")
49+
table.add_column("Baseline", justify="right")
50+
table.add_column("Current", justify="right")
51+
table.add_column("Deviation", justify="right")
52+
table.add_column("Z-Score", justify="right")
53+
table.add_column("Severity")
54+
table.add_column("Impact")
55+
56+
ordered_results = sorted(
57+
analysis.drift_results.items(),
58+
key=lambda item: (-SEVERITY_SCORE[item[1].severity], item[0]),
59+
)
60+
61+
for task_id, result in ordered_results:
62+
impact = analysis.task_impacts.get(task_id)
63+
table.add_row(
64+
task_id,
65+
f"{result.baseline:.2f}s",
66+
f"{result.current:.2f}s",
67+
f"{result.deviation_percent:+.1f}%",
68+
f"{result.robust_z_score:.2f}",
69+
_severity_text(result.severity),
70+
str(impact.classification) if impact else "-",
71+
)
72+
73+
console.print(table)
74+
75+
76+
def _render_handoff_drift(console: Console, analysis: DAGAnalysis) -> None:
77+
if not analysis.handoff_drift_results:
78+
return
79+
80+
table = Table(title="Handoff Drift")
81+
table.add_column("Edge")
82+
table.add_column("Baseline", justify="right")
83+
table.add_column("Current", justify="right")
84+
table.add_column("Deviation", justify="right")
85+
table.add_column("Z-Score", justify="right")
86+
table.add_column("Severity")
87+
88+
ordered_results = sorted(
89+
analysis.handoff_drift_results.items(),
90+
key=lambda item: (-SEVERITY_SCORE[item[1].severity], item[0]),
91+
)
92+
93+
for (upstream, downstream), result in ordered_results:
94+
table.add_row(
95+
f"{upstream} -> {downstream}",
96+
f"{result.baseline:.2f}s",
97+
f"{result.current:.2f}s",
98+
f"{result.deviation_percent:+.1f}%",
99+
f"{result.robust_z_score:.2f}",
100+
_severity_text(result.severity),
101+
)
102+
103+
console.print(table)
104+
105+
106+
def _render_change_points(console: Console, analysis: DAGAnalysis) -> None:
107+
results = [
108+
*analysis.change_point_results.values(),
109+
*analysis.handoff_change_point_results.values(),
110+
]
111+
if not results:
112+
return
113+
114+
table = Table(title="Change Points")
115+
table.add_column("Subject")
116+
table.add_column("Direction")
117+
table.add_column("Observation", justify="right")
118+
table.add_column("Before", justify="right")
119+
table.add_column("After", justify="right")
120+
table.add_column("Change", justify="right")
121+
table.add_column("Score", justify="right")
122+
123+
for result in sorted(results, key=lambda item: item.subject_id):
124+
table.add_row(
125+
result.subject_id,
126+
str(result.direction),
127+
str(result.change_index + 1),
128+
f"{result.before_median:.2f}s",
129+
f"{result.after_median:.2f}s",
130+
_percent(result.change_percent),
131+
f"{result.score:.2f}",
132+
)
133+
134+
console.print(table)
135+
136+
137+
def _render_trends(console: Console, analysis: DAGAnalysis) -> None:
138+
results = [
139+
*analysis.trend_results.values(),
140+
*analysis.handoff_trend_results.values(),
141+
]
142+
if not results:
143+
return
144+
145+
table = Table(title="Trends")
146+
table.add_column("Subject")
147+
table.add_column("Direction")
148+
table.add_column("Slope / run", justify="right")
149+
table.add_column("Est. change", justify="right")
150+
table.add_column("Change", justify="right")
151+
table.add_column("Consistency", justify="right")
152+
table.add_column("Score", justify="right")
153+
154+
for result in sorted(results, key=lambda item: item.subject_id):
155+
table.add_row(
156+
result.subject_id,
157+
str(result.direction),
158+
f"{result.slope_per_observation:+.2f}s",
159+
f"{result.estimated_change:+.2f}s",
160+
_percent(result.change_percent),
161+
f"{result.directional_consistency:.0%}",
162+
f"{result.score:.2f}",
163+
)
164+
165+
console.print(table)
166+
167+
168+
def _render_propagation(console: Console, analysis: DAGAnalysis) -> None:
169+
if not analysis.propagation_results:
170+
return
171+
172+
table = Table(title="Propagation")
173+
table.add_column("Origin")
174+
table.add_column("Path")
175+
table.add_column("Affected", justify="right")
176+
table.add_column("Score", justify="right")
177+
178+
for result in sorted(
179+
analysis.propagation_results,
180+
key=lambda item: (item.origin_task, item.path),
181+
):
182+
table.add_row(
183+
result.origin_task,
184+
" -> ".join(result.path),
185+
str(len(result.affected_tasks)),
186+
f"{result.propagation_score:.2f}",
187+
)
188+
189+
console.print(table)
190+
191+
192+
def _render_diagnostics(console: Console, analysis: DAGAnalysis) -> None:
193+
if not analysis.diagnostics:
194+
return
195+
196+
table = Table(title="Diagnostics", title_style="bold yellow")
197+
table.add_column("Code", style="yellow")
198+
table.add_column("Subject")
199+
table.add_column("Message")
200+
201+
for diagnostic in sorted(
202+
analysis.diagnostics,
203+
key=lambda item: (item.code, item.subject_id),
204+
):
205+
table.add_row(diagnostic.code, diagnostic.subject_id, diagnostic.message)
206+
207+
console.print(table)
208+
209+
210+
def render_analysis(console: Console, analysis: DAGAnalysis) -> None:
211+
"""Render a complete human-readable analysis report."""
212+
_render_summary(console, analysis)
213+
_render_task_drift(console, analysis)
214+
_render_handoff_drift(console, analysis)
215+
_render_change_points(console, analysis)
216+
_render_trends(console, analysis)
217+
_render_propagation(console, analysis)
218+
_render_diagnostics(console, analysis)

0 commit comments

Comments
 (0)