detectkit 0.46.0__tar.gz → 0.48.0__tar.gz

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 (147) hide show
  1. {detectkit-0.46.0/detectkit.egg-info → detectkit-0.48.0}/PKG-INFO +5 -1
  2. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/channels/base.py +17 -0
  4. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_base.py +5 -0
  5. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_decision.py +2 -0
  6. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_recovery.py +1 -0
  7. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/CLAUDE.section.md +1 -0
  8. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/alerting.md +1 -0
  9. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/cli.md +53 -3
  10. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/metrics.md +16 -0
  11. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +11 -3
  12. detectkit-0.48.0/detectkit/cli/commands/osi.py +245 -0
  13. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/commands/run.py +48 -47
  14. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/commands/tune.py +6 -1
  15. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/main.py +8 -0
  16. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/config/metric_config.py +76 -1
  17. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/config/validator.py +40 -4
  18. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/_alert_step.py +1 -0
  19. detectkit-0.48.0/detectkit/semantic/__init__.py +41 -0
  20. detectkit-0.48.0/detectkit/semantic/errors.py +31 -0
  21. detectkit-0.48.0/detectkit/semantic/exporter.py +102 -0
  22. detectkit-0.48.0/detectkit/semantic/importer.py +281 -0
  23. detectkit-0.48.0/detectkit/semantic/osi_model.py +221 -0
  24. detectkit-0.48.0/detectkit/semantic/query_gen.py +229 -0
  25. detectkit-0.48.0/detectkit/tuning/assets/tune.js +187 -0
  26. detectkit-0.48.0/detectkit/tuning/config_writer.py +253 -0
  27. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/tuning/payload.py +101 -7
  28. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/tuning/server.py +47 -5
  29. {detectkit-0.46.0 → detectkit-0.48.0/detectkit.egg-info}/PKG-INFO +5 -1
  30. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit.egg-info/SOURCES.txt +7 -0
  31. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit.egg-info/requires.txt +5 -0
  32. {detectkit-0.46.0 → detectkit-0.48.0}/pyproject.toml +7 -0
  33. detectkit-0.46.0/detectkit/tuning/assets/tune.js +0 -187
  34. detectkit-0.46.0/detectkit/tuning/config_writer.py +0 -138
  35. {detectkit-0.46.0 → detectkit-0.48.0}/LICENSE +0 -0
  36. {detectkit-0.46.0 → detectkit-0.48.0}/MANIFEST.in +0 -0
  37. {detectkit-0.46.0 → detectkit-0.48.0}/README.md +0 -0
  38. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/__init__.py +0 -0
  39. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/channels/__init__.py +0 -0
  40. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/channels/branding.py +0 -0
  41. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/channels/email.py +0 -0
  42. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/channels/factory.py +0 -0
  43. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/channels/mattermost.py +0 -0
  44. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/channels/slack.py +0 -0
  45. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/channels/telegram.py +0 -0
  46. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/channels/webhook.py +0 -0
  47. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  48. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  49. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  50. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
  51. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  52. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  53. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/__init__.py +0 -0
  54. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/_base.py +0 -0
  55. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/_types.py +0 -0
  56. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/autotuner.py +0 -0
  57. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/config_emitter.py +0 -0
  58. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/crossval.py +0 -0
  59. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/detector_select.py +0 -0
  60. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/distribution.py +0 -0
  61. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/grid_search.py +0 -0
  62. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/labels.py +0 -0
  63. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/result.py +0 -0
  64. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/runner.py +0 -0
  65. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/scoring.py +0 -0
  66. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/seasonality_search.py +0 -0
  67. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/settings.py +0 -0
  68. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/window_select.py +0 -0
  69. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/__init__.py +0 -0
  70. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/_output.py +0 -0
  71. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
  72. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  73. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  74. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  75. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
  76. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  77. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  78. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  79. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/commands/__init__.py +0 -0
  80. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/commands/autotune.py +0 -0
  81. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/commands/clean.py +0 -0
  82. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/commands/init.py +0 -0
  83. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/commands/init_claude.py +0 -0
  84. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/commands/test_alert.py +0 -0
  85. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/commands/unlock.py +0 -0
  86. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/config/__init__.py +0 -0
  87. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/config/profile.py +0 -0
  88. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/config/project_config.py +0 -0
  89. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/core/__init__.py +0 -0
  90. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/core/interval.py +0 -0
  91. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/core/models.py +0 -0
  92. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/__init__.py +0 -0
  93. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/_sql_manager.py +0 -0
  94. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/clickhouse_manager.py +0 -0
  95. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/__init__.py +0 -0
  96. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  97. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  98. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_base.py +0 -0
  99. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  100. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_detections.py +0 -0
  101. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  102. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  103. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_schema.py +0 -0
  104. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  105. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/manager.py +0 -0
  106. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/manager.py +0 -0
  107. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/mysql_manager.py +0 -0
  108. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/postgres_manager.py +0 -0
  109. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/tables.py +0 -0
  110. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/detectors/__init__.py +0 -0
  111. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/detectors/base.py +0 -0
  112. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/detectors/factory.py +0 -0
  113. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/detectors/seasonality.py +0 -0
  114. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/detectors/statistical/__init__.py +0 -0
  115. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  116. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/detectors/statistical/iqr.py +0 -0
  117. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/detectors/statistical/mad.py +0 -0
  118. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  119. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/detectors/statistical/zscore.py +0 -0
  120. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/loaders/__init__.py +0 -0
  121. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/loaders/metric_loader.py +0 -0
  122. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/loaders/query_template.py +0 -0
  123. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/orchestration/__init__.py +0 -0
  124. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/orchestration/error_dispatch.py +0 -0
  125. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  126. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  127. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  128. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  129. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  130. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  131. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/reporting/__init__.py +0 -0
  132. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/reporting/assets/report.js +0 -0
  133. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/reporting/builder.py +0 -0
  134. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/reporting/html_report.py +0 -0
  135. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/tuning/__init__.py +0 -0
  136. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/tuning/html.py +0 -0
  137. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/utils/__init__.py +0 -0
  138. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/utils/datetime_utils.py +0 -0
  139. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/utils/env_interpolation.py +0 -0
  140. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/utils/json_utils.py +0 -0
  141. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/utils/stats.py +0 -0
  142. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit.egg-info/dependency_links.txt +0 -0
  143. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit.egg-info/entry_points.txt +0 -0
  144. {detectkit-0.46.0 → detectkit-0.48.0}/detectkit.egg-info/top_level.txt +0 -0
  145. {detectkit-0.46.0 → detectkit-0.48.0}/requirements.txt +0 -0
  146. {detectkit-0.46.0 → detectkit-0.48.0}/setup.cfg +0 -0
  147. {detectkit-0.46.0 → detectkit-0.48.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.46.0
3
+ Version: 0.48.0
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
@@ -47,12 +47,15 @@ Requires-Dist: timesfm>=0.1.0; extra == "timesfm"
47
47
  Provides-Extra: advanced-detectors
48
48
  Requires-Dist: prophet>=1.1.0; extra == "advanced-detectors"
49
49
  Requires-Dist: timesfm>=0.1.0; extra == "advanced-detectors"
50
+ Provides-Extra: osi
51
+ Requires-Dist: sqlglot>=20.0; extra == "osi"
50
52
  Provides-Extra: all
51
53
  Requires-Dist: clickhouse-driver>=0.2.0; extra == "all"
52
54
  Requires-Dist: psycopg2-binary>=2.9.0; extra == "all"
53
55
  Requires-Dist: pymysql>=1.0.0; extra == "all"
54
56
  Requires-Dist: prophet>=1.1.0; extra == "all"
55
57
  Requires-Dist: timesfm>=0.1.0; extra == "all"
58
+ Requires-Dist: sqlglot>=20.0; extra == "all"
56
59
  Provides-Extra: dev
57
60
  Requires-Dist: pytest>=7.0; extra == "dev"
58
61
  Requires-Dist: pytest-cov>=4.0; extra == "dev"
@@ -61,6 +64,7 @@ Requires-Dist: requests-mock>=1.12; extra == "dev"
61
64
  Requires-Dist: black>=23.0; extra == "dev"
62
65
  Requires-Dist: mypy>=1.0; extra == "dev"
63
66
  Requires-Dist: ruff>=0.1.0; extra == "dev"
67
+ Requires-Dist: sqlglot>=20.0; extra == "dev"
64
68
  Provides-Extra: integration
65
69
  Requires-Dist: testcontainers[clickhouse,mysql,postgres]>=4.0; extra == "integration"
66
70
  Requires-Dist: psycopg2-binary>=2.9.0; extra == "integration"
@@ -4,7 +4,7 @@ detectk - Anomaly Detection for Time-Series Metrics
4
4
  A Python library for data analysts and engineers to monitor metrics with automatic anomaly detection.
5
5
  """
6
6
 
7
- __version__ = "0.46.0"
7
+ __version__ = "0.48.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -72,6 +72,13 @@ class AlertData:
72
72
  error_message: str | None = None
73
73
  description: str | None = None
74
74
  mentions: list[str] = field(default_factory=list)
75
+ # OSI-compatible AI grounding: the metric's alternative names. Exposed to
76
+ # message templates as the OPT-IN ``{synonyms}`` / ``{synonyms_line}``
77
+ # variables (via :meth:`build_context`) so a custom template can surface an
78
+ # "Also known as: …" line — but the DEFAULT rendering deliberately does NOT
79
+ # show it, so existing alerts are byte-identical. Stamped by the orchestrator
80
+ # from ``MetricConfig.ai_context.synonyms``; defaults to empty.
81
+ ai_synonyms: list[str] = field(default_factory=list)
75
82
  project_name: str | None = None
76
83
  # Optional actionable links surfaced in the message. ``dashboard_url`` is the
77
84
  # headline link (rendered natively as a clickable title/link on
@@ -416,6 +423,14 @@ class BaseAlertChannel(ABC):
416
423
  # Format description line (empty string if no description)
417
424
  description_line = f"{alert_data.description}\n" if alert_data.description else ""
418
425
 
426
+ # OSI ai_context synonyms — the metric's alternative names. Provided as
427
+ # OPT-IN template variables only (``{synonyms}`` / ``{synonyms_line}``);
428
+ # the default templates do NOT reference them, so a metric without
429
+ # ai_context — and every existing alert — renders unchanged. A custom
430
+ # template can drop ``{synonyms_line}`` in to show "Also known as: …".
431
+ synonyms = ", ".join(alert_data.ai_synonyms) if alert_data.ai_synonyms else ""
432
+ synonyms_line = f"Also known as: {synonyms}\n" if synonyms else ""
433
+
419
434
  # Format mentions
420
435
  mentions_str = self.format_mentions(alert_data.mentions)
421
436
  mentions_line = f"\n{mentions_str}" if mentions_str else ""
@@ -482,6 +497,8 @@ class BaseAlertChannel(ABC):
482
497
  "error_message": alert_data.error_message or "",
483
498
  "description": alert_data.description or "",
484
499
  "description_line": description_line,
500
+ "synonyms": synonyms,
501
+ "synonyms_line": synonyms_line,
485
502
  "dashboard_url": dashboard_url,
486
503
  "dashboard_line": dashboard_line,
487
504
  "help_url": help_url,
@@ -36,6 +36,7 @@ class _OrchestratorBase:
36
36
  links: dict[str, str] | None = None,
37
37
  project_name: str | None = None,
38
38
  help_url: str | None = None,
39
+ ai_synonyms: list[str] | None = None,
39
40
  ):
40
41
  self.metric_name = metric_name
41
42
  self.interval = interval
@@ -57,6 +58,10 @@ class _OrchestratorBase:
57
58
  # defaulting to the official docs; None when opted out). Stamped onto every
58
59
  # AlertData so channels render a guide link for non-operator stakeholders.
59
60
  self.help_url = help_url
61
+ # OSI ai_context synonyms (the metric's alternative names). Stamped onto
62
+ # every AlertData so channels can render an "Also known as" identity line.
63
+ # Empty list when the metric has no ai_context — renders unchanged.
64
+ self.ai_synonyms = ai_synonyms or []
60
65
 
61
66
  @staticmethod
62
67
  def _group_by_timestamp(
@@ -280,6 +280,7 @@ class _DecisionMixin(_OrchestratorBase):
280
280
  consecutive_count=consecutive_count,
281
281
  description=self.description,
282
282
  mentions=self.mentions,
283
+ ai_synonyms=self.ai_synonyms,
283
284
  dashboard_url=self.dashboard_url,
284
285
  links=self.links,
285
286
  project_name=self.project_name,
@@ -345,6 +346,7 @@ class _DecisionMixin(_OrchestratorBase):
345
346
  is_no_data=True,
346
347
  description=self.description,
347
348
  mentions=self.mentions,
349
+ ai_synonyms=self.ai_synonyms,
348
350
  dashboard_url=self.dashboard_url,
349
351
  links=self.links,
350
352
  project_name=self.project_name,
@@ -188,6 +188,7 @@ class _RecoveryMixin(_OrchestratorBase):
188
188
  is_recovery=True,
189
189
  description=self.description,
190
190
  mentions=self.mentions,
191
+ ai_synonyms=self.ai_synonyms,
191
192
  dashboard_url=self.dashboard_url,
192
193
  links=self.links,
193
194
  project_name=self.project_name,
@@ -40,6 +40,7 @@ version — **read the relevant one on demand** instead of guessing:
40
40
  | Alert rules (quorum/direction/consecutive), cooldown, recovery, templates | `.claude/rules/detectkit/alerting.md` |
41
41
  | Auto-tuning a detector (`dtk autotune`), labels file, the `autotune:` block, `_dtk_autotune_runs` | `.claude/rules/detectkit/autotune.md` |
42
42
  | Manually/interactively tuning a detector in the browser and writing it back in place (`dtk tune`) | `.claude/rules/detectkit/cli.md` |
43
+ | OSI (Open Semantic Interchange) interop: import an OSI metric into a native metric / export back (`dtk osi`) | `.claude/rules/detectkit/cli.md` |
43
44
 
44
45
  ### Skills
45
46
 
@@ -297,6 +297,7 @@ referenced by path). Key variables:
297
297
  | Variable | Meaning |
298
298
  |---|---|
299
299
  | `{metric_name}`, `{description}` / `{description_line}` | identity |
300
+ | `{synonyms}` / `{synonyms_line}` | alt names from `ai_context.synonyms` (opt-in; not in default templates — add `{synonyms_line}` for an "Also known as: …" line) |
300
301
  | `{project_name}` / `{project_name_prefix}` | project label (`"[name] "` prefix, or `""`) |
301
302
  | `{timestamp}`, `{timezone}` | when (display tz via `alerting.timezone`, default UTC) |
302
303
  | `{value}` / `{value_display}` | metric value (`value_display` is NaN-safe) |
@@ -15,6 +15,8 @@ Run all commands from a project directory (the one containing
15
15
  | `dtk test-alert <metric>` | Send a mock alert to the metric's channels |
16
16
  | `dtk unlock --select <sel>` | Clear a stuck pipeline lock |
17
17
  | `dtk clean --select <sel>` | Prune internal data that no longer matches the config |
18
+ | `dtk osi import <model>` | Scaffold a native metric from an OSI semantic-model metric (see "OSI interop") |
19
+ | `dtk osi export` | Publish metrics into an OSI fragment (config in `custom_extensions[detectkit]`) |
18
20
  | `dtk --version` | Show installed detectkit version |
19
21
 
20
22
  ## Selectors (`--select` / `-s`)
@@ -95,7 +97,14 @@ anomalies**) and the **y = 0** toggle — stay visible in every mode. The chart
95
97
  **zoomable** (scroll/drag + navigator strip) with a **"Points shown"** trim slider.
96
98
  Clicking **Apply** writes the chosen
97
99
  config back into the metric YAML **in place** (autotune, by contrast, writes a new
98
- `__tuned_<id>.yml` and never edits the original). Reads the metric's loaded
100
+ `__tuned_<id>.yml` and never edits the original). Apply **merges** it rewrites
101
+ only the detector(s) you tuned and keeps every **other** detector unchanged, so a
102
+ `manual_bounds` floor alongside a `mad` detector (and a `min_detectors: 2` alert)
103
+ survives a retune. If a metric configures **more than one detector**, a **Tuning
104
+ detector** picker in the Tune rail lets you choose which one to tune (the cockpit
105
+ shows one band at a time); switching re-seeds every knob from that detector, and
106
+ non-tunable detectors (`prophet`/`timesfm`) plus the ones you didn't touch are
107
+ listed as "preserved on Apply". Reads the metric's loaded
99
108
  datapoints (run `dtk run --steps load` first if empty); the selector must resolve
100
109
  to a single metric.
101
110
 
@@ -153,8 +162,11 @@ relative to zero.
153
162
 
154
163
  Safe write-back: the config is validated before anything is written, the previous
155
164
  YAML is archived under `metrics/.history/<metric>/`, and only then is the metric
156
- overwritten. Takes **no pipeline lock** (it only edits a config file); re-run
157
- `dtk run` afterward to recompute detections under the new config.
165
+ overwritten (the merge keeps your other detectors verbatim; the re-emitted header
166
+ names what was updated vs preserved). The `.history/` archive is **not** discovered
167
+ as a live metric, so a tuned metric never collides with its own snapshots as a
168
+ "duplicate metric name". Takes **no pipeline lock** (it only edits a config file);
169
+ re-run `dtk run` afterward to recompute detections under the new config.
158
170
  `--no-serve` writes a static read-only preview HTML instead (no write-back —
159
171
  **Save incidents** downloads the labels file); `--from` / `--to` bound the window;
160
172
  `--no-open` prints the URL without opening a browser.
@@ -193,6 +205,44 @@ actually delete.
193
205
  unless `-y/--yes`; refuses to run if the project defines no metrics or configs
194
206
  fail to parse (so a wrong directory can't wipe valid data).
195
207
 
208
+ ## OSI interop (`dtk osi`)
209
+
210
+ `dtk osi` converts between [Open Semantic Interchange](https://github.com/open-semantic-interchange/OSI)
211
+ (OSI) semantic models and native detectkit metrics. It's a **separate, additive**
212
+ layer: it never runs the pipeline, takes no lock, and the converter package
213
+ (`detectkit/semantic/`) is not imported by load/detect/alert — so it can't affect
214
+ a running project. OSI is treated as an *interchange* format (define the KPI once,
215
+ consume in BI + AI), not an execution engine. OSI adoption is still early, so the
216
+ broadly-useful piece today is `ai_context` (see the metrics rule) — the converters
217
+ are a forward bridge for teams that already run a governed semantic layer.
218
+
219
+ - `dtk osi import <model.osi.yml> --metric <name> --interval <grain>` — the
220
+ "enhanced init": resolve one OSI metric and **scaffold a normal native metric**
221
+ (SQL `query`, interval, a starter detector, the metric's `ai_context`). Review
222
+ it, then commit like any hand-written metric. Targets:
223
+ - `--target clickhouse` (default) — a direct `toStartOfInterval(...) GROUP BY`
224
+ query from the dataset's physical `source` (ANSI→ClickHouse via **sqlglot**,
225
+ the optional `[osi]` extra: `pip install 'detectkit[osi]'`).
226
+ - `--target cube --cube <name> --time-field <dim>` — a Cube **SQL-API**
227
+ `MEASURE(...)` query, so the metric runs through Cube and the alert matches a
228
+ Cube dashboard's number by construction. Point its `profile` at a Postgres
229
+ connection on Cube's SQL port.
230
+ - Only provably per-bucket-additive measures compile (`SUM`/`COUNT`/`COUNT(DISTINCT)`/
231
+ `AVG`/`MIN`/`MAX` + ratios like `SUM(x)/NULLIF(COUNT(DISTINCT y),0)`). Window
232
+ functions / non-aggregates / unknown aggregates are **refused** with a message
233
+ to use `query_file:` — never a plausible-but-wrong series.
234
+ - `dtk osi compile <model> --metric <name> --interval <g>` prints just the SQL
235
+ (for review). `dtk osi export [--select <sel>]` writes an OSI fragment carrying
236
+ a lossless snapshot of the config in `custom_extensions[detectkit]` + the
237
+ `ai_context` — a **one-way carrier** (`import` does not reconstruct from it; the
238
+ metric YAML stays the source of truth).
239
+
240
+ ```bash
241
+ dtk osi compile model.osi.yml -m total_sales -i 1h # preview the SQL
242
+ dtk osi import model.osi.yml -m total_sales -i 1h -o metrics/ # scaffold a metric
243
+ dtk osi export -o semantic/detectkit.osi.yml # publish back to OSI
244
+ ```
245
+
196
246
  ## Common workflows
197
247
 
198
248
  ```bash
@@ -13,6 +13,9 @@ description: API p95 latency # optional, shown in alerts
13
13
  profile: prod # optional, overrides project default_profile
14
14
  enabled: true # optional, false → skipped by `dtk run`
15
15
  tags: [critical, api] # optional, used by `--select tag:<t>`
16
+ ai_context: # optional — OSI-compatible grounding (descriptive only)
17
+ instructions: "p95 API latency, ms; user-facing"
18
+ synonyms: ["api latency", "response time"]
16
19
 
17
20
  interval: 5min # required — point spacing on the time grid
18
21
 
@@ -63,6 +66,19 @@ false_alert_budget: 0.3 # optional — `dtk tune` target false-alert rate
63
66
  project-wide default; unset → project, then a built-in `0.5`. Tuning-only — it never
64
67
  affects the load/detect/alert pipeline.
65
68
 
69
+ `ai_context` is **OSI-compatible grounding** you can add to any metric with **no
70
+ OSI model needed** — the metric's business meaning (`instructions`), alternative
71
+ names (`synonyms`) and example values (`examples`), mirroring the
72
+ [Open Semantic Interchange](https://github.com/open-semantic-interchange/OSI)
73
+ `ai_context` shape so a metric's meaning is portable to/from an OSI semantic model.
74
+ Accepts a bare string (→ `instructions`) or the full mapping. It is **purely
75
+ descriptive**: it never affects load/detect/alert or the detector id, and it does
76
+ **not** change any default-rendered alert. The `synonyms` are exposed to alert
77
+ templates as the **opt-in** `{synonyms}` / `{synonyms_line}` variables (a custom
78
+ `template` can add an "Also known as: …" line), and the whole block is carried in
79
+ the `dtk tune` cockpit payload as read-only grounding. Omit it and everything
80
+ renders as before.
81
+
66
82
  ## `interval` (required)
67
83
 
68
84
  Point spacing. String (`"30s"`, `"1min"`, `"5min"`, `"10min"`, `"1hour"`,
@@ -146,9 +146,17 @@ false-alert rate exceeds it (tuning-only; labeling stays optional).
146
146
  When the user is happy, they click **Apply to metric**. detectkit then, in order:
147
147
  **validates** the config (a bad/untunable config is rejected and **nothing is
148
148
  written**), **archives** the current YAML verbatim under
149
- `metrics/.history/<metric>/`, and **re-emits** the metric in place with the tuned
150
- detector (and updates the first alerting block's `consecutive_anomalies` if it has
151
- one). Applying ends the session; saving incidents does not.
149
+ `metrics/.history/<metric>/`, and **re-emits** the metric in place, **merging** the
150
+ tuned detector(s) back in (and updating the first alerting block's
151
+ `consecutive_anomalies` if it has one). Applying ends the session; saving incidents
152
+ does not. **Merge, not replace:** only the detector(s) the user tuned are
153
+ rewritten; every other detector is kept **verbatim** — so a metric with a
154
+ `manual_bounds` floor alongside a `mad` detector (and a `min_detectors: 2` alert)
155
+ keeps both. When a metric has more than one detector, the Tune rail shows a
156
+ **Tuning detector** picker to choose which one to tune. If the user asks to tune a
157
+ metric that has several detectors, reassure them the others are preserved. (The
158
+ `.history/` archive is not discovered as a live metric, so it never causes a
159
+ "duplicate metric name" error.)
152
160
 
153
161
  ## Step 5 — Recompute under the new config
154
162
 
@@ -0,0 +1,245 @@
1
+ """``dtk osi`` — Open Semantic Interchange interop (import / export / compile).
2
+
3
+ A self-contained, additive command group. It never runs the pipeline, takes no
4
+ lock, and touches no internal table:
5
+
6
+ - ``dtk osi import`` scaffolds a native detectkit metric from an OSI model metric
7
+ (review the output, then commit it like any hand-written metric).
8
+ - ``dtk osi export`` publishes detectkit metrics into an OSI fragment (a one-way
9
+ carrier: the full config rides in ``custom_extensions[detectkit]``; ``import``
10
+ does not reconstruct from it).
11
+ - ``dtk osi compile`` prints just the SQL an import would generate (for review).
12
+
13
+ Needs the optional ``[osi]`` extra (sqlglot) only for the ClickHouse target.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import click
23
+ import yaml
24
+
25
+
26
+ @click.group()
27
+ def osi() -> None:
28
+ """Open Semantic Interchange (OSI) interop: import / export / compile metrics."""
29
+
30
+
31
+ def _split_csv(value: str | None) -> list[str] | None:
32
+ if not value:
33
+ return None
34
+ items = [v.strip() for v in value.split(",") if v.strip()]
35
+ return items or None
36
+
37
+
38
+ def _dump_metric_yaml(body: dict[str, Any], header: str) -> str:
39
+ """Render a metric body as YAML with a ``#`` provenance header (mirrors config_emitter)."""
40
+ text = yaml.safe_dump(body, sort_keys=False, allow_unicode=True, width=100)
41
+ return header + text
42
+
43
+
44
+ def _import_header(result: Any, model_file: Path) -> str:
45
+ lines = [
46
+ "# Generated by `dtk osi import` — review before committing.",
47
+ f"# Source OSI model: {model_file}",
48
+ f"# Metric: {result.metric_name} · target: {result.target} · "
49
+ f"sql-fingerprint: {result.fingerprint}",
50
+ "# detectkit resumes from the last datapoint, so if you re-import and this",
51
+ "# fingerprint changes, backfill/clean to avoid mixing definitions.",
52
+ ]
53
+ for w in result.warnings:
54
+ lines.append(f"# NOTE: {w}")
55
+ return "\n".join(lines) + "\n"
56
+
57
+
58
+ @osi.command(name="import")
59
+ @click.argument("model_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
60
+ @click.option("--metric", "-m", required=True, help="OSI metric name to import")
61
+ @click.option("--interval", "-i", required=True, help="Series grain (e.g. 1h, 10min, 3600)")
62
+ @click.option(
63
+ "--target",
64
+ type=click.Choice(["clickhouse", "cube"]),
65
+ default="clickhouse",
66
+ show_default=True,
67
+ help="clickhouse = direct SQL from dataset.source; cube = Cube SQL-API (MEASURE)",
68
+ )
69
+ @click.option("--dataset", help="OSI dataset to build FROM (default: the model's only dataset)")
70
+ @click.option("--time-field", help="Time grain column / dimension (default: a field with is_time)")
71
+ @click.option("--where", help="Extra SQL predicate (ANSI) added to the WHERE clause")
72
+ @click.option("--cube", help="Cube name (cube target)")
73
+ @click.option("--cube-measure", help="Cube measure name (cube target; default: the metric name)")
74
+ @click.option("--time-dimension", help="Cube time dimension (cube target)")
75
+ @click.option("--seasonality", help="Comma-separated time-derived seasonality columns")
76
+ @click.option("--detector", default="mad", show_default=True, help="Starter detector type")
77
+ @click.option(
78
+ "--out",
79
+ "-o",
80
+ type=click.Path(path_type=Path),
81
+ help="Write the metric YAML here (a dir → <name>.yml). Default: print to stdout.",
82
+ )
83
+ @click.option("--force", is_flag=True, help="Overwrite an existing --out file")
84
+ def import_(
85
+ model_file: Path,
86
+ metric: str,
87
+ interval: str,
88
+ target: str,
89
+ dataset: str | None,
90
+ time_field: str | None,
91
+ where: str | None,
92
+ cube: str | None,
93
+ cube_measure: str | None,
94
+ time_dimension: str | None,
95
+ seasonality: str | None,
96
+ detector: str,
97
+ out: Path | None,
98
+ force: bool,
99
+ ) -> None:
100
+ """Scaffold a native detectkit metric from an OSI MODEL_FILE metric."""
101
+ from detectkit.semantic import OsiError, import_osi_metric, load_osi_models
102
+
103
+ try:
104
+ models = load_osi_models(model_file)
105
+ result = import_osi_metric(
106
+ models=models,
107
+ metric_name=metric,
108
+ interval=interval,
109
+ target=target,
110
+ dataset=dataset,
111
+ time_field=time_field,
112
+ where=where,
113
+ cube=cube,
114
+ cube_measure=cube_measure,
115
+ time_dimension=time_dimension,
116
+ seasonality=_split_csv(seasonality),
117
+ detector_type=detector,
118
+ )
119
+ except OsiError as exc:
120
+ raise click.ClickException(str(exc)) from exc
121
+
122
+ rendered = _dump_metric_yaml(result.metric, _import_header(result, model_file))
123
+
124
+ if out is None:
125
+ click.echo(rendered)
126
+ click.echo("# (printed only — pass --out to write a metric file)", err=True)
127
+ return
128
+
129
+ dest = out / f"{result.metric_name}.yml" if out.is_dir() else out
130
+ if dest.exists() and not force:
131
+ raise click.ClickException(f"{dest} exists — pass --force to overwrite")
132
+ dest.parent.mkdir(parents=True, exist_ok=True)
133
+ dest.write_text(rendered)
134
+ click.echo(f"Wrote {dest}")
135
+ for w in result.warnings:
136
+ click.echo(f" note: {w}")
137
+
138
+
139
+ @osi.command()
140
+ @click.argument("model_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
141
+ @click.option("--metric", "-m", required=True, help="OSI metric name")
142
+ @click.option("--interval", "-i", required=True, help="Series grain (e.g. 1h, 10min, 3600)")
143
+ @click.option("--target", type=click.Choice(["clickhouse", "cube"]), default="clickhouse")
144
+ @click.option("--dataset", help="OSI dataset to build FROM")
145
+ @click.option("--time-field", help="Time grain column / dimension")
146
+ @click.option("--where", help="Extra SQL predicate (ANSI)")
147
+ @click.option("--cube", help="Cube name (cube target)")
148
+ @click.option("--cube-measure", help="Cube measure name (cube target)")
149
+ @click.option("--time-dimension", help="Cube time dimension (cube target)")
150
+ def compile( # noqa: A001 - mirrors the `dtk osi compile` verb
151
+ model_file: Path,
152
+ metric: str,
153
+ interval: str,
154
+ target: str,
155
+ dataset: str | None,
156
+ time_field: str | None,
157
+ where: str | None,
158
+ cube: str | None,
159
+ cube_measure: str | None,
160
+ time_dimension: str | None,
161
+ ) -> None:
162
+ """Print ONLY the SQL an import would generate (no file written)."""
163
+ from detectkit.semantic import OsiError, import_osi_metric, load_osi_models
164
+
165
+ try:
166
+ models = load_osi_models(model_file)
167
+ result = import_osi_metric(
168
+ models=models,
169
+ metric_name=metric,
170
+ interval=interval,
171
+ target=target,
172
+ dataset=dataset,
173
+ time_field=time_field,
174
+ where=where,
175
+ cube=cube,
176
+ cube_measure=cube_measure,
177
+ time_dimension=time_dimension,
178
+ )
179
+ except OsiError as exc:
180
+ raise click.ClickException(str(exc)) from exc
181
+ click.echo(result.sql)
182
+
183
+
184
+ @osi.command()
185
+ @click.option(
186
+ "--select", "-s", "selector", help="Metric selector (name / path / tag:<t>); default: all"
187
+ )
188
+ @click.option("--model-name", default="detectkit_export", show_default=True, help="OSI model name")
189
+ @click.option(
190
+ "--out",
191
+ "-o",
192
+ type=click.Path(path_type=Path),
193
+ help="Write the OSI YAML here. Default: print to stdout.",
194
+ )
195
+ @click.option("--force", is_flag=True, help="Overwrite an existing --out file")
196
+ def export(selector: str | None, model_name: str, out: Path | None, force: bool) -> None:
197
+ """Export native detectkit metrics into an OSI fragment (config in custom_extensions)."""
198
+ from detectkit.cli.commands.run import find_project_root, select_metrics
199
+ from detectkit.config.validator import validate_project_metrics
200
+ from detectkit.semantic import export_models
201
+
202
+ root = find_project_root()
203
+ if root is None:
204
+ raise click.ClickException(
205
+ "not inside a detectkit project (no detectkit_project.yml found)"
206
+ )
207
+
208
+ try:
209
+ pairs = select_metrics(selector, root) if selector else validate_project_metrics(root)
210
+ except Exception as exc:
211
+ raise click.ClickException(str(exc)) from exc
212
+ configs = [cfg for _, cfg in pairs]
213
+ if not configs:
214
+ raise click.ClickException("no metrics selected")
215
+
216
+ doc = export_models(configs, model_name=model_name)
217
+ rendered = yaml.safe_dump(doc, sort_keys=False, allow_unicode=True, width=100)
218
+ header = (
219
+ f"# Generated by `dtk osi export` — {len(configs)} metric(s).\n"
220
+ "# The portable measure is a placeholder; the exact detectkit definition\n"
221
+ "# (detectors, alerting, query) rides in custom_extensions[detectkit].\n"
222
+ "# One-way carrier: `dtk osi import` does not reconstruct from this block —\n"
223
+ "# keep your metric YAML as the source of truth.\n"
224
+ )
225
+ rendered = header + rendered
226
+
227
+ if out is None:
228
+ click.echo(rendered)
229
+ return
230
+ if out.exists() and not force:
231
+ raise click.ClickException(f"{out} exists — pass --force to overwrite")
232
+ out.parent.mkdir(parents=True, exist_ok=True)
233
+ out.write_text(rendered)
234
+ click.echo(f"Wrote {out} ({len(configs)} metric(s))", err=False)
235
+ click.echo(
236
+ " note: one-way carrier — `dtk osi import` does not reconstruct from "
237
+ "custom_extensions[detectkit]; keep your metric YAML as the source of truth.",
238
+ err=True,
239
+ )
240
+
241
+
242
+ def run_osi() -> None:
243
+ """Entry shim (kept symmetric with other commands; the group is wired in main)."""
244
+ osi(standalone_mode=True) # pragma: no cover
245
+ sys.exit(0) # pragma: no cover
@@ -12,7 +12,11 @@ import click
12
12
  from detectkit.config.metric_config import MetricConfig
13
13
  from detectkit.config.profile import ProfilesConfig
14
14
  from detectkit.config.project_config import ProjectConfig
15
- from detectkit.config.validator import validate_metric_uniqueness
15
+ from detectkit.config.validator import (
16
+ discover_metric_files,
17
+ is_discoverable_metric_file,
18
+ validate_metric_uniqueness,
19
+ )
16
20
  from detectkit.database.internal_tables import InternalTablesManager
17
21
  from detectkit.orchestration.error_dispatch import dispatch_project_error_alert
18
22
  from detectkit.orchestration.task_manager import PipelineStep, TaskManager
@@ -433,17 +437,16 @@ def select_metrics(selector: str, project_root: Path) -> list[tuple[Path, Metric
433
437
  if selector == "*":
434
438
  # "all metrics" — search recursively so nested metrics are included
435
439
  # (mirrors validate_project_metrics); a plain glob of "metrics/*"
436
- # would only see the top level.
437
- metric_paths = [p for sub in ("**/*.yml", "**/*.yaml") for p in metrics_dir.glob(sub)]
440
+ # would only see the top level. Excludes the metrics/.history/ archive.
441
+ metric_paths = discover_metric_files(metrics_dir)
438
442
  else:
439
443
  pattern = selector if selector.startswith("metrics/") else f"metrics/{selector}"
440
444
  # Keep only metric files: a bare glob also matches the `.gitkeep`
441
- # stub created by `dtk init`, any other non-YAML files, and
442
- # directories all of which would crash the YAML parser.
445
+ # stub created by `dtk init`, any other non-YAML files, directories, and
446
+ # the metrics/.history/ archive dtk tune writes (whose snapshots keep the
447
+ # original name:) — all of which would crash the parser or collide on name.
443
448
  metric_paths = [
444
- p
445
- for p in project_root.glob(pattern)
446
- if p.is_file() and p.suffix in (".yml", ".yaml")
449
+ p for p in project_root.glob(pattern) if is_discoverable_metric_file(p, metrics_dir)
447
450
  ]
448
451
  # Metric name selector
449
452
  else:
@@ -485,26 +488,25 @@ def find_metrics_by_tag(metrics_dir: Path, tag: str) -> list[Path]:
485
488
 
486
489
  matching_metrics = []
487
490
 
488
- # Search both .yml and .yaml extensions (consistent with find_metric_by_name)
489
- for pattern in ["**/*.yml", "**/*.yaml"]:
490
- for metric_file in metrics_dir.glob(pattern):
491
- try:
492
- with open(metric_file) as f:
493
- config = yaml.safe_load(f)
494
-
495
- if config and "tags" in config:
496
- if tag in config["tags"]:
497
- matching_metrics.append(metric_file)
498
- except Exception as e:
499
- # Warn about unparseable files but continue searching
500
- click.echo(
501
- click.style(
502
- f"Warning: Skipping {metric_file.relative_to(metrics_dir.parent)}: {e}",
503
- fg="yellow",
504
- ),
505
- err=True,
506
- )
507
- continue
491
+ # Live metric files only (excludes the metrics/.history/ archive).
492
+ for metric_file in discover_metric_files(metrics_dir):
493
+ try:
494
+ with open(metric_file) as f:
495
+ config = yaml.safe_load(f)
496
+
497
+ if config and "tags" in config:
498
+ if tag in config["tags"]:
499
+ matching_metrics.append(metric_file)
500
+ except Exception as e:
501
+ # Warn about unparseable files but continue searching
502
+ click.echo(
503
+ click.style(
504
+ f"Warning: Skipping {metric_file.relative_to(metrics_dir.parent)}: {e}",
505
+ fg="yellow",
506
+ ),
507
+ err=True,
508
+ )
509
+ continue
508
510
 
509
511
  return matching_metrics
510
512
 
@@ -522,25 +524,24 @@ def find_metric_by_name(metrics_dir: Path, name: str) -> Path | None:
522
524
  """
523
525
  import yaml
524
526
 
525
- # Search both .yml and .yaml extensions
526
- for pattern in ["**/*.yml", "**/*.yaml"]:
527
- for metric_file in metrics_dir.glob(pattern):
528
- try:
529
- with open(metric_file) as f:
530
- config = yaml.safe_load(f)
531
-
532
- if config and config.get("name") == name:
533
- return metric_file
534
- except Exception as e:
535
- # Warn about unparseable files but continue searching
536
- click.echo(
537
- click.style(
538
- f"Warning: Skipping {metric_file.relative_to(metrics_dir.parent)}: {e}",
539
- fg="yellow",
540
- ),
541
- err=True,
542
- )
543
- continue
527
+ # Live metric files only (excludes the metrics/.history/ archive).
528
+ for metric_file in discover_metric_files(metrics_dir):
529
+ try:
530
+ with open(metric_file) as f:
531
+ config = yaml.safe_load(f)
532
+
533
+ if config and config.get("name") == name:
534
+ return metric_file
535
+ except Exception as e:
536
+ # Warn about unparseable files but continue searching
537
+ click.echo(
538
+ click.style(
539
+ f"Warning: Skipping {metric_file.relative_to(metrics_dir.parent)}: {e}",
540
+ fg="yellow",
541
+ ),
542
+ err=True,
543
+ )
544
+ continue
544
545
 
545
546
  return None
546
547