detectkit 0.2.4__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.
Files changed (51) hide show
  1. detectkit/__init__.py +17 -0
  2. detectkit/alerting/__init__.py +13 -0
  3. detectkit/alerting/channels/__init__.py +21 -0
  4. detectkit/alerting/channels/base.py +193 -0
  5. detectkit/alerting/channels/email.py +146 -0
  6. detectkit/alerting/channels/factory.py +193 -0
  7. detectkit/alerting/channels/mattermost.py +53 -0
  8. detectkit/alerting/channels/slack.py +55 -0
  9. detectkit/alerting/channels/telegram.py +110 -0
  10. detectkit/alerting/channels/webhook.py +139 -0
  11. detectkit/alerting/orchestrator.py +369 -0
  12. detectkit/cli/__init__.py +1 -0
  13. detectkit/cli/commands/__init__.py +1 -0
  14. detectkit/cli/commands/init.py +282 -0
  15. detectkit/cli/commands/run.py +486 -0
  16. detectkit/cli/commands/test_alert.py +184 -0
  17. detectkit/cli/main.py +186 -0
  18. detectkit/config/__init__.py +30 -0
  19. detectkit/config/metric_config.py +499 -0
  20. detectkit/config/profile.py +285 -0
  21. detectkit/config/project_config.py +164 -0
  22. detectkit/config/validator.py +124 -0
  23. detectkit/core/__init__.py +6 -0
  24. detectkit/core/interval.py +132 -0
  25. detectkit/core/models.py +106 -0
  26. detectkit/database/__init__.py +27 -0
  27. detectkit/database/clickhouse_manager.py +393 -0
  28. detectkit/database/internal_tables.py +724 -0
  29. detectkit/database/manager.py +324 -0
  30. detectkit/database/tables.py +138 -0
  31. detectkit/detectors/__init__.py +6 -0
  32. detectkit/detectors/base.py +441 -0
  33. detectkit/detectors/factory.py +138 -0
  34. detectkit/detectors/statistical/__init__.py +8 -0
  35. detectkit/detectors/statistical/iqr.py +508 -0
  36. detectkit/detectors/statistical/mad.py +478 -0
  37. detectkit/detectors/statistical/manual_bounds.py +206 -0
  38. detectkit/detectors/statistical/zscore.py +491 -0
  39. detectkit/loaders/__init__.py +6 -0
  40. detectkit/loaders/metric_loader.py +470 -0
  41. detectkit/loaders/query_template.py +164 -0
  42. detectkit/orchestration/__init__.py +9 -0
  43. detectkit/orchestration/task_manager.py +746 -0
  44. detectkit/utils/__init__.py +17 -0
  45. detectkit/utils/stats.py +196 -0
  46. detectkit-0.2.4.dist-info/METADATA +237 -0
  47. detectkit-0.2.4.dist-info/RECORD +51 -0
  48. detectkit-0.2.4.dist-info/WHEEL +5 -0
  49. detectkit-0.2.4.dist-info/entry_points.txt +2 -0
  50. detectkit-0.2.4.dist-info/licenses/LICENSE +21 -0
  51. detectkit-0.2.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,486 @@
1
+ """
2
+ Implementation of 'dtk run' command.
3
+
4
+ Executes metric processing pipeline.
5
+ """
6
+
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import List, Optional
10
+
11
+ import click
12
+
13
+ from detectkit.config.metric_config import MetricConfig
14
+ from detectkit.config.profile import ProfilesConfig
15
+ from detectkit.config.validator import validate_metric_uniqueness
16
+ from detectkit.database.internal_tables import InternalTablesManager
17
+ from detectkit.orchestration.task_manager import PipelineStep, TaskManager
18
+
19
+
20
+ def run_command(
21
+ select: str,
22
+ exclude: Optional[str],
23
+ steps: str,
24
+ from_date: Optional[str],
25
+ to_date: Optional[str],
26
+ full_refresh: bool,
27
+ force: bool,
28
+ profile: Optional[str],
29
+ ):
30
+ """
31
+ Execute metric processing pipeline.
32
+
33
+ Args:
34
+ select: Metric selector (name, path, or tag)
35
+ exclude: Metrics to exclude (name, path, or tag)
36
+ steps: Comma-separated pipeline steps
37
+ from_date: Start date string
38
+ to_date: End date string
39
+ full_refresh: Delete and reload all data
40
+ force: Ignore task locks
41
+ profile: Profile name to use
42
+ """
43
+ # Parse steps
44
+ step_list = parse_steps(steps)
45
+
46
+ # Parse dates
47
+ from_dt = parse_date(from_date) if from_date else None
48
+ to_dt = parse_date(to_date) if to_date else None
49
+
50
+ # Find project root and load config
51
+ project_root = find_project_root()
52
+ if not project_root:
53
+ click.echo(
54
+ click.style(
55
+ "Error: Not in a detectkit project directory!",
56
+ fg="red",
57
+ bold=True,
58
+ )
59
+ )
60
+ click.echo("Run 'dtk init <project_name>' to create a new project.")
61
+ return
62
+
63
+ click.echo(f"Project root: {project_root}")
64
+
65
+ # Load project config
66
+ # project_config = load_project_config(project_root)
67
+
68
+ # Select metrics based on selector
69
+ # Returns list of (path, config) tuples with uniqueness validation
70
+ try:
71
+ metrics = select_metrics(select, project_root)
72
+ except ValueError as e:
73
+ click.echo(
74
+ click.style(
75
+ f"Error: {e}",
76
+ fg="red",
77
+ bold=True,
78
+ )
79
+ )
80
+ return
81
+
82
+ # Exclude metrics if specified
83
+ if exclude:
84
+ try:
85
+ excluded_metrics = select_metrics(exclude, project_root)
86
+ excluded_names = {config.name for _, config in excluded_metrics}
87
+ metrics = [(path, config) for path, config in metrics if config.name not in excluded_names]
88
+
89
+ if excluded_metrics:
90
+ click.echo(f"Excluded {len(excluded_metrics)} metric(s) matching: {exclude}")
91
+ except ValueError as e:
92
+ click.echo(
93
+ click.style(
94
+ f"Error in exclusion selector: {e}",
95
+ fg="red",
96
+ bold=True,
97
+ )
98
+ )
99
+ return
100
+
101
+ if not metrics:
102
+ click.echo(
103
+ click.style(
104
+ f"No metrics found matching selector: {select}",
105
+ fg="yellow",
106
+ )
107
+ )
108
+ return
109
+
110
+ click.echo(f"Found {len(metrics)} metric(s) to process")
111
+ click.echo()
112
+
113
+ # Load profiles.yml
114
+ profiles_path = project_root / "profiles.yml"
115
+ if not profiles_path.exists():
116
+ click.echo(
117
+ click.style(
118
+ "Error: profiles.yml not found!",
119
+ fg="red",
120
+ bold=True,
121
+ )
122
+ )
123
+ click.echo(f"Expected at: {profiles_path}")
124
+ return
125
+
126
+ try:
127
+ profiles_config = ProfilesConfig.from_yaml(profiles_path)
128
+ except Exception as e:
129
+ click.echo(
130
+ click.style(
131
+ f"Error loading profiles.yml: {e}",
132
+ fg="red",
133
+ bold=True,
134
+ )
135
+ )
136
+ return
137
+
138
+ # Create database manager
139
+ try:
140
+ db_manager = profiles_config.create_manager(profile)
141
+ except Exception as e:
142
+ click.echo(
143
+ click.style(
144
+ f"Error creating database manager: {e}",
145
+ fg="red",
146
+ bold=True,
147
+ )
148
+ )
149
+ return
150
+
151
+ # Create internal tables manager
152
+ internal_manager = InternalTablesManager(db_manager)
153
+
154
+ # Initialize internal tables if needed
155
+ try:
156
+ internal_manager.ensure_tables()
157
+ except Exception as e:
158
+ click.echo(
159
+ click.style(
160
+ f"Error initializing internal tables: {e}",
161
+ fg="red",
162
+ bold=True,
163
+ )
164
+ )
165
+ return
166
+
167
+ # Create task manager
168
+ task_manager = TaskManager(
169
+ internal_manager=internal_manager,
170
+ db_manager=db_manager,
171
+ profiles_config=profiles_config,
172
+ )
173
+
174
+ # Process each metric
175
+ for metric_path, config in metrics:
176
+ process_metric(
177
+ metric_path=metric_path,
178
+ config=config,
179
+ project_root=project_root,
180
+ task_manager=task_manager,
181
+ steps=step_list,
182
+ from_date=from_dt,
183
+ to_date=to_dt,
184
+ full_refresh=full_refresh,
185
+ force=force,
186
+ )
187
+
188
+
189
+ def parse_steps(steps_str: str) -> List[PipelineStep]:
190
+ """
191
+ Parse comma-separated steps string.
192
+
193
+ Args:
194
+ steps_str: Comma-separated steps (e.g., "load,detect,alert")
195
+
196
+ Returns:
197
+ List of PipelineStep enums
198
+
199
+ Example:
200
+ >>> parse_steps("load,detect")
201
+ [PipelineStep.LOAD, PipelineStep.DETECT]
202
+ """
203
+ step_map = {
204
+ "load": PipelineStep.LOAD,
205
+ "detect": PipelineStep.DETECT,
206
+ "alert": PipelineStep.ALERT,
207
+ }
208
+
209
+ steps = []
210
+ for step_str in steps_str.split(","):
211
+ step_str = step_str.strip().lower()
212
+ if step_str not in step_map:
213
+ raise click.BadParameter(
214
+ f"Invalid step: {step_str}. Valid steps: load, detect, alert"
215
+ )
216
+ steps.append(step_map[step_str])
217
+
218
+ return steps
219
+
220
+
221
+ def parse_date(date_str: str) -> datetime:
222
+ """
223
+ Parse date string to datetime.
224
+
225
+ Supports formats:
226
+ - YYYY-MM-DD
227
+ - YYYY-MM-DD HH:MM:SS
228
+
229
+ Args:
230
+ date_str: Date string
231
+
232
+ Returns:
233
+ datetime object
234
+
235
+ Raises:
236
+ click.BadParameter: If date format is invalid
237
+ """
238
+ formats = [
239
+ "%Y-%m-%d",
240
+ "%Y-%m-%d %H:%M:%S",
241
+ ]
242
+
243
+ for fmt in formats:
244
+ try:
245
+ return datetime.strptime(date_str, fmt)
246
+ except ValueError:
247
+ continue
248
+
249
+ raise click.BadParameter(
250
+ f"Invalid date format: {date_str}. "
251
+ f"Use YYYY-MM-DD or 'YYYY-MM-DD HH:MM:SS'"
252
+ )
253
+
254
+
255
+ def find_project_root() -> Optional[Path]:
256
+ """
257
+ Find detectkit project root by looking for detectkit_project.yml.
258
+
259
+ Searches current directory and parent directories.
260
+
261
+ Returns:
262
+ Path to project root or None if not found
263
+ """
264
+ current = Path.cwd()
265
+
266
+ # Search up to 10 levels up
267
+ for _ in range(10):
268
+ if (current / "detectkit_project.yml").exists():
269
+ return current
270
+
271
+ if current.parent == current:
272
+ # Reached filesystem root
273
+ break
274
+
275
+ current = current.parent
276
+
277
+ return None
278
+
279
+
280
+ def select_metrics(selector: str, project_root: Path) -> List[tuple[Path, MetricConfig]]:
281
+ """
282
+ Select metrics based on selector and validate uniqueness.
283
+
284
+ Selector types:
285
+ - Metric name: "cpu_usage" (searches by 'name' field recursively in subdirectories)
286
+ - Path pattern: "metrics/critical/*.yml" or "league/cpu_usage"
287
+ - Tag: "tag:critical"
288
+
289
+ For name selector:
290
+ 1. First tries filename-based search in root metrics/ directory
291
+ 2. If not found, searches recursively by 'name' field in all subdirectories
292
+
293
+ Args:
294
+ selector: Selector string
295
+ project_root: Project root path
296
+
297
+ Returns:
298
+ List of (path, config) tuples for selected metrics
299
+
300
+ Raises:
301
+ ValueError: If duplicate metric names found or configs invalid
302
+ """
303
+ metrics_dir = project_root / "metrics"
304
+
305
+ if not metrics_dir.exists():
306
+ return []
307
+
308
+ # Collect metric paths based on selector
309
+ metric_paths: List[Path] = []
310
+
311
+ # Tag selector
312
+ if selector.startswith("tag:"):
313
+ tag = selector[4:]
314
+ metric_paths = find_metrics_by_tag(metrics_dir, tag)
315
+ # Path pattern selector
316
+ elif "*" in selector or "/" in selector:
317
+ pattern = selector if selector.startswith("metrics/") else f"metrics/{selector}"
318
+ metric_paths = list(project_root.glob(pattern))
319
+ # Metric name selector
320
+ else:
321
+ # First try filename-based search in root (backward compatibility)
322
+ metric_file = metrics_dir / f"{selector}.yml"
323
+ if metric_file.exists():
324
+ metric_paths = [metric_file]
325
+ else:
326
+ # Try with .yaml extension
327
+ metric_file = metrics_dir / f"{selector}.yaml"
328
+ if metric_file.exists():
329
+ metric_paths = [metric_file]
330
+ else:
331
+ # Fall back to recursive search by 'name' field
332
+ found_metric = find_metric_by_name(metrics_dir, selector)
333
+ if found_metric:
334
+ metric_paths = [found_metric]
335
+
336
+ if not metric_paths:
337
+ return []
338
+
339
+ # Validate uniqueness and load configs
340
+ # This will raise ValueError if duplicate metric names found
341
+ return validate_metric_uniqueness(metric_paths)
342
+
343
+
344
+ def find_metrics_by_tag(metrics_dir: Path, tag: str) -> List[Path]:
345
+ """
346
+ Find all metrics with specific tag.
347
+
348
+ Args:
349
+ metrics_dir: Metrics directory path
350
+ tag: Tag to search for
351
+
352
+ Returns:
353
+ List of metric paths with this tag
354
+ """
355
+ import yaml
356
+
357
+ matching_metrics = []
358
+
359
+ for metric_file in metrics_dir.glob("**/*.yml"):
360
+ try:
361
+ with open(metric_file) as f:
362
+ config = yaml.safe_load(f)
363
+
364
+ if config and "tags" in config:
365
+ if tag in config["tags"]:
366
+ matching_metrics.append(metric_file)
367
+ except Exception:
368
+ # Skip files that can't be parsed
369
+ continue
370
+
371
+ return matching_metrics
372
+
373
+
374
+ def find_metric_by_name(metrics_dir: Path, name: str) -> Optional[Path]:
375
+ """
376
+ Find metric by name field (searches recursively in subdirectories).
377
+
378
+ Args:
379
+ metrics_dir: Metrics directory path
380
+ name: Metric name to search for (from 'name' field in YAML)
381
+
382
+ Returns:
383
+ Path to metric file if found, None otherwise
384
+ """
385
+ import yaml
386
+
387
+ # Search both .yml and .yaml extensions
388
+ for pattern in ["**/*.yml", "**/*.yaml"]:
389
+ for metric_file in metrics_dir.glob(pattern):
390
+ try:
391
+ with open(metric_file) as f:
392
+ config = yaml.safe_load(f)
393
+
394
+ if config and config.get("name") == name:
395
+ return metric_file
396
+ except Exception:
397
+ # Skip files that can't be parsed
398
+ continue
399
+
400
+ return None
401
+
402
+
403
+ def process_metric(
404
+ metric_path: Path,
405
+ config: MetricConfig,
406
+ project_root: Path,
407
+ task_manager: TaskManager,
408
+ steps: List[PipelineStep],
409
+ from_date: Optional[datetime],
410
+ to_date: Optional[datetime],
411
+ full_refresh: bool,
412
+ force: bool,
413
+ ):
414
+ """
415
+ Process a single metric.
416
+
417
+ Args:
418
+ metric_path: Path to metric YAML file
419
+ config: Loaded and validated metric configuration
420
+ project_root: Project root directory
421
+ task_manager: Task manager instance
422
+ steps: Pipeline steps to execute
423
+ from_date: Start date
424
+ to_date: End date
425
+ full_refresh: Full refresh flag
426
+ force: Force flag
427
+ """
428
+ # Use config.name (not metric_path.stem) for consistency
429
+ metric_name = config.name
430
+
431
+ click.echo(click.style(f"Processing metric: {metric_name}", fg="cyan", bold=True))
432
+ click.echo(f" Config file: {metric_path.relative_to(project_root)}")
433
+ click.echo(f" Steps: {', '.join(s.value for s in steps)}")
434
+
435
+ if from_date:
436
+ click.echo(f" From: {from_date}")
437
+ if to_date:
438
+ click.echo(f" To: {to_date}")
439
+ if full_refresh:
440
+ click.echo(click.style(" Full refresh: YES", fg="yellow"))
441
+ if force:
442
+ click.echo(click.style(" Force: YES (ignoring locks)", fg="yellow"))
443
+
444
+ click.echo()
445
+
446
+ # Run pipeline
447
+ try:
448
+ # Log step headers
449
+ if PipelineStep.LOAD in steps:
450
+ click.echo()
451
+ click.echo(click.style(" ┌─ LOAD", fg="cyan", bold=True))
452
+
453
+ result = task_manager.run_metric(
454
+ config=config,
455
+ steps=steps,
456
+ from_date=from_date,
457
+ to_date=to_date,
458
+ full_refresh=full_refresh,
459
+ force=force,
460
+ )
461
+
462
+ # Display results - task_manager already printed details
463
+ click.echo()
464
+ if result["status"] == "success":
465
+ click.echo(click.style("✓ Pipeline completed successfully", fg="green", bold=True))
466
+ else:
467
+ click.echo(
468
+ click.style(
469
+ f" ✗ Failed: {result['error']}",
470
+ fg="red",
471
+ bold=True,
472
+ )
473
+ )
474
+
475
+ except Exception as e:
476
+ click.echo(
477
+ click.style(
478
+ f" ✗ Pipeline error: {e}",
479
+ fg="red",
480
+ bold=True,
481
+ )
482
+ )
483
+ import traceback
484
+ click.echo(traceback.format_exc())
485
+
486
+ click.echo()
@@ -0,0 +1,184 @@
1
+ """
2
+ Test alert command - send test alert to configured channels.
3
+
4
+ Allows testing alert rendering and channel delivery without real anomalies.
5
+ Useful for:
6
+ - Verifying Mattermost/Slack message formatting
7
+ - Testing webhook connectivity
8
+ - Previewing alert templates
9
+ """
10
+
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ import numpy as np
16
+
17
+ from detectkit.alerting.channels.base import AlertData
18
+ from detectkit.alerting.channels.factory import AlertChannelFactory
19
+ from detectkit.config.metric_config import MetricConfig
20
+
21
+
22
+ def create_mock_alert_data(
23
+ metric_config: MetricConfig,
24
+ timezone_display: str = "UTC",
25
+ ) -> AlertData:
26
+ """
27
+ Create realistic mock AlertData for testing.
28
+
29
+ Args:
30
+ metric_config: Metric configuration
31
+ timezone_display: Timezone for display
32
+
33
+ Returns:
34
+ AlertData with mock anomaly data
35
+ """
36
+ # Use current time
37
+ now = datetime.now(timezone.utc)
38
+
39
+ # Create realistic mock data
40
+ return AlertData(
41
+ metric_name=metric_config.name,
42
+ timestamp=np.datetime64(now, "ms"),
43
+ timezone=timezone_display,
44
+ value=0.8532, # Mock anomalous value
45
+ confidence_lower=0.4521,
46
+ confidence_upper=0.6234,
47
+ detector_name="MADDetector:threshold=3.0",
48
+ detector_params='{"threshold": 3.0, "window_size": 8640}',
49
+ direction="above",
50
+ severity=4.52,
51
+ detection_metadata={
52
+ "global_median": 0.5123,
53
+ "adjusted_median": 0.5234,
54
+ "seasonality_groups": [
55
+ {
56
+ "group": ["offset_10minutes", "league_day"],
57
+ "median_multiplier": 1.023,
58
+ "mad_multiplier": 0.876,
59
+ "group_size": 23,
60
+ }
61
+ ],
62
+ },
63
+ consecutive_count=3,
64
+ )
65
+
66
+
67
+ def run_test_alert(metric_name: str, profile: Optional[str] = None):
68
+ """
69
+ Send test alert for specified metric.
70
+
71
+ Args:
72
+ metric_name: Name of metric to test alert for
73
+ profile: Optional profile override
74
+ """
75
+ # Load project config
76
+ project_root = Path.cwd()
77
+ project_config_path = project_root / "detectkit_project.yml"
78
+
79
+ if not project_config_path.exists():
80
+ print("Error: No detectkit_project.yml found in current directory")
81
+ print("Run this command from your detectkit project root")
82
+ return
83
+
84
+ # Load project config manually (avoid validation issues)
85
+ import yaml
86
+
87
+ with open(project_config_path) as f:
88
+ project_data = yaml.safe_load(f)
89
+
90
+ metrics_dir_name = project_data.get("metrics_path", "metrics")
91
+
92
+ # Find metric config
93
+ metrics_dir = project_root / metrics_dir_name
94
+ metric_files = list(metrics_dir.glob("**/*.yml")) + list(
95
+ metrics_dir.glob("**/*.yaml")
96
+ )
97
+
98
+ metric_config = None
99
+ for metric_file in metric_files:
100
+ try:
101
+ config = MetricConfig.from_yaml_file(metric_file)
102
+ if config.name == metric_name:
103
+ metric_config = config
104
+ break
105
+ except Exception:
106
+ continue
107
+
108
+ if not metric_config:
109
+ print(f"Error: Metric '{metric_name}' not found")
110
+ print(f"Searched in: {metrics_dir}")
111
+ return
112
+
113
+ # Check if alerting is configured
114
+ if not metric_config.alerting or not metric_config.alerting.enabled:
115
+ print(f"Error: Alerting not enabled for metric '{metric_name}'")
116
+ print("Enable alerting in metric config (alerting.enabled: true)")
117
+ return
118
+
119
+ if not metric_config.alerting.channels:
120
+ print(f"Error: No alert channels configured for metric '{metric_name}'")
121
+ print("Add channels in metric config (alerting.channels: [...])")
122
+ return
123
+
124
+ # Load profiles
125
+ profiles_path = project_root / "profiles.yml"
126
+ if not profiles_path.exists():
127
+ print("Error: profiles.yml not found")
128
+ return
129
+
130
+ import yaml
131
+
132
+ with open(profiles_path) as f:
133
+ profiles_data = yaml.safe_load(f)
134
+
135
+ alert_channels_config = profiles_data.get("alert_channels", {})
136
+
137
+ # Get timezone for display
138
+ timezone_display = metric_config.alerting.timezone or "UTC"
139
+
140
+ # Create mock alert data
141
+ print(f"\n📨 Sending test alert for metric: {metric_name}")
142
+ print(f" Timezone: {timezone_display}")
143
+ print(f" Channels: {', '.join(metric_config.alerting.channels)}\n")
144
+
145
+ alert_data = create_mock_alert_data(metric_config, timezone_display)
146
+
147
+ # Send to each configured channel
148
+ success_count = 0
149
+ for channel_name in metric_config.alerting.channels:
150
+ if channel_name not in alert_channels_config:
151
+ print(f"⚠️ Channel '{channel_name}' not found in profiles.yml - skipping")
152
+ continue
153
+
154
+ channel_config = alert_channels_config[channel_name]
155
+
156
+ try:
157
+ # Create channel instance
158
+ # channel_config должен содержать 'type' + остальные параметры
159
+ channel = AlertChannelFactory.create_from_config(channel_config)
160
+
161
+ # Get custom template if configured
162
+ template = None
163
+ if metric_config.alerting.template_consecutive:
164
+ template = metric_config.alerting.template_consecutive
165
+
166
+ # Send alert
167
+ print(f" → Sending to {channel_name}...", end=" ")
168
+ success = channel.send(alert_data, template=template)
169
+
170
+ if success:
171
+ print("✓ SUCCESS")
172
+ success_count += 1
173
+ else:
174
+ print("✗ FAILED")
175
+
176
+ except Exception as e:
177
+ print(f"✗ ERROR: {e}")
178
+
179
+ # Summary
180
+ print(f"\n{'✓' if success_count > 0 else '✗'} Sent test alert to {success_count}/{len(metric_config.alerting.channels)} channels")
181
+
182
+ if success_count > 0:
183
+ print("\n💡 Check your configured channels to verify message formatting")
184
+ print(f" Mock data used: value=0.8532, confidence=[0.4521, 0.6234], severity=4.52")