detectkit 0.45.1__tar.gz → 0.47.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 (145) hide show
  1. {detectkit-0.45.1/detectkit.egg-info → detectkit-0.47.0}/PKG-INFO +5 -1
  2. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/alerting/channels/base.py +17 -0
  4. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/alerting/channels/webhook.py +145 -129
  5. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/alerting/orchestrator/_base.py +5 -0
  6. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/alerting/orchestrator/_decision.py +2 -0
  7. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/alerting/orchestrator/_recovery.py +1 -0
  8. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/assets/claude/CLAUDE.section.md +1 -0
  9. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/assets/claude/rules/alerting.md +21 -20
  10. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/assets/claude/rules/cli.md +40 -0
  11. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/assets/claude/rules/metrics.md +16 -0
  12. detectkit-0.47.0/detectkit/cli/commands/osi.py +245 -0
  13. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/main.py +8 -0
  14. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/config/metric_config.py +76 -1
  15. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/orchestration/task_manager/_alert_step.py +1 -0
  16. detectkit-0.47.0/detectkit/semantic/__init__.py +41 -0
  17. detectkit-0.47.0/detectkit/semantic/errors.py +31 -0
  18. detectkit-0.47.0/detectkit/semantic/exporter.py +102 -0
  19. detectkit-0.47.0/detectkit/semantic/importer.py +281 -0
  20. detectkit-0.47.0/detectkit/semantic/osi_model.py +221 -0
  21. detectkit-0.47.0/detectkit/semantic/query_gen.py +229 -0
  22. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/tuning/payload.py +20 -0
  23. {detectkit-0.45.1 → detectkit-0.47.0/detectkit.egg-info}/PKG-INFO +5 -1
  24. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit.egg-info/SOURCES.txt +7 -0
  25. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit.egg-info/requires.txt +5 -0
  26. {detectkit-0.45.1 → detectkit-0.47.0}/pyproject.toml +7 -0
  27. {detectkit-0.45.1 → detectkit-0.47.0}/LICENSE +0 -0
  28. {detectkit-0.45.1 → detectkit-0.47.0}/MANIFEST.in +0 -0
  29. {detectkit-0.45.1 → detectkit-0.47.0}/README.md +0 -0
  30. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/alerting/__init__.py +0 -0
  31. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/alerting/channels/__init__.py +0 -0
  32. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/alerting/channels/branding.py +0 -0
  33. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/alerting/channels/email.py +0 -0
  34. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/alerting/channels/factory.py +0 -0
  35. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/alerting/channels/mattermost.py +0 -0
  36. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/alerting/channels/slack.py +0 -0
  37. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/alerting/channels/telegram.py +0 -0
  38. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  39. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  40. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  41. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
  42. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  43. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  44. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/autotune/__init__.py +0 -0
  45. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/autotune/_base.py +0 -0
  46. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/autotune/_types.py +0 -0
  47. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/autotune/autotuner.py +0 -0
  48. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/autotune/config_emitter.py +0 -0
  49. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/autotune/crossval.py +0 -0
  50. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/autotune/detector_select.py +0 -0
  51. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/autotune/distribution.py +0 -0
  52. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/autotune/grid_search.py +0 -0
  53. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/autotune/labels.py +0 -0
  54. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/autotune/result.py +0 -0
  55. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/autotune/runner.py +0 -0
  56. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/autotune/scoring.py +0 -0
  57. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/autotune/seasonality_search.py +0 -0
  58. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/autotune/settings.py +0 -0
  59. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/autotune/window_select.py +0 -0
  60. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/__init__.py +0 -0
  61. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/_output.py +0 -0
  62. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
  63. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  64. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  65. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  66. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
  67. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  68. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  69. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  70. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +0 -0
  71. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/commands/__init__.py +0 -0
  72. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/commands/autotune.py +0 -0
  73. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/commands/clean.py +0 -0
  74. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/commands/init.py +0 -0
  75. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/commands/init_claude.py +0 -0
  76. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/commands/run.py +0 -0
  77. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/commands/test_alert.py +0 -0
  78. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/commands/tune.py +0 -0
  79. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/cli/commands/unlock.py +0 -0
  80. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/config/__init__.py +0 -0
  81. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/config/profile.py +0 -0
  82. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/config/project_config.py +0 -0
  83. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/config/validator.py +0 -0
  84. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/core/__init__.py +0 -0
  85. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/core/interval.py +0 -0
  86. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/core/models.py +0 -0
  87. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/database/__init__.py +0 -0
  88. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/database/_sql_manager.py +0 -0
  89. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/database/clickhouse_manager.py +0 -0
  90. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/database/internal_tables/__init__.py +0 -0
  91. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  92. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  93. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/database/internal_tables/_base.py +0 -0
  94. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  95. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/database/internal_tables/_detections.py +0 -0
  96. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  97. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  98. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/database/internal_tables/_schema.py +0 -0
  99. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  100. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/database/internal_tables/manager.py +0 -0
  101. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/database/manager.py +0 -0
  102. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/database/mysql_manager.py +0 -0
  103. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/database/postgres_manager.py +0 -0
  104. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/database/tables.py +0 -0
  105. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/detectors/__init__.py +0 -0
  106. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/detectors/base.py +0 -0
  107. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/detectors/factory.py +0 -0
  108. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/detectors/seasonality.py +0 -0
  109. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/detectors/statistical/__init__.py +0 -0
  110. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  111. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/detectors/statistical/iqr.py +0 -0
  112. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/detectors/statistical/mad.py +0 -0
  113. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  114. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/detectors/statistical/zscore.py +0 -0
  115. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/loaders/__init__.py +0 -0
  116. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/loaders/metric_loader.py +0 -0
  117. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/loaders/query_template.py +0 -0
  118. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/orchestration/__init__.py +0 -0
  119. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/orchestration/error_dispatch.py +0 -0
  120. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  121. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  122. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  123. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  124. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  125. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  126. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/reporting/__init__.py +0 -0
  127. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/reporting/assets/report.js +0 -0
  128. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/reporting/builder.py +0 -0
  129. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/reporting/html_report.py +0 -0
  130. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/tuning/__init__.py +0 -0
  131. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/tuning/assets/tune.js +0 -0
  132. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/tuning/config_writer.py +0 -0
  133. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/tuning/html.py +0 -0
  134. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/tuning/server.py +0 -0
  135. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/utils/__init__.py +0 -0
  136. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/utils/datetime_utils.py +0 -0
  137. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/utils/env_interpolation.py +0 -0
  138. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/utils/json_utils.py +0 -0
  139. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit/utils/stats.py +0 -0
  140. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit.egg-info/dependency_links.txt +0 -0
  141. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit.egg-info/entry_points.txt +0 -0
  142. {detectkit-0.45.1 → detectkit-0.47.0}/detectkit.egg-info/top_level.txt +0 -0
  143. {detectkit-0.45.1 → detectkit-0.47.0}/requirements.txt +0 -0
  144. {detectkit-0.45.1 → detectkit-0.47.0}/setup.cfg +0 -0
  145. {detectkit-0.45.1 → detectkit-0.47.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.45.1
3
+ Version: 0.47.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.45.1"
7
+ __version__ = "0.47.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,
@@ -25,22 +25,23 @@ class WebhookChannel(BaseAlertChannel):
25
25
  - Slack incoming webhooks
26
26
  - Custom webhook endpoints
27
27
 
28
- Rendering: the default (no custom ``template``) anomaly/recovery payload is
29
- **two stacked Slack/Mattermost attachments** a colored **base card** that
30
- is always visible (the title, a short markdown lead with the **Rule** chip,
31
- the Value / Expected fields and an always-visible compact **Links** field)
32
- and a neutral **detail card** carrying the verbose tail (Quorum / Severity /
33
- the anomalous span / Detectors / Parameters) as one markdown ``text`` block.
34
- Slack and Mattermost natively collapse only an attachment's ``text`` (Slack
35
- above 700 characters / 5 line breaks, Mattermost above ~200px of rendered
36
- height) and **never** collapse the ``fields`` grid so routing the bulk
37
- into the detail card's ``text`` lets the platform fold it behind a
38
- "Show more" toggle while the base (value, expected and links) stays in view.
39
- Short kinds (no-data / error) render as a single base card. A custom
40
- ``template`` degrades to a plain text-only attachment (one opaque string
41
- that can't be sliced into fields), keeping the color, title and branding.
42
- Branding (``footer`` + ``footer_icon``) rides on the **last** attachment so
43
- it sits at the bottom of the whole message, below the folded detail card.
28
+ Rendering: the default (no custom ``template``) payload is a **single
29
+ status-colored Slack/Mattermost attachment** whose whole body rides in one
30
+ markdown ``text`` block, ordered most-important-first the lead + **Rule**
31
+ chip, then Value / Expected, the action **Links**, and finally the verbose
32
+ evidence tail (Quorum / Severity / the anomalous span / Detectors /
33
+ Parameters). Both platforms natively collapse a long attachment ``text``
34
+ behind a **"Show more"** toggle (Slack above 700 characters / 5 line breaks;
35
+ Mattermost wraps only the ``text`` in its ``maxHeight`` 200px fold — the
36
+ ``title``, the color bar and the ``footer`` render *outside* that fold), so a
37
+ long anomaly folds its tail exactly like a reference AlertManager alert while
38
+ the one colored bar, the clickable title and the **branded footer (with the
39
+ logo)** stay in view even when the body is collapsed. No-data / error alerts
40
+ stay short, single un-folded cards; a long anomaly or a full recovery
41
+ timeline (onset fired recovered) folds its tail. A custom ``template``
42
+ renders the same shape one colored, branded, text-only attachment.
43
+ Branding (``footer`` + ``footer_icon``, the brand logo) rides on that single
44
+ attachment and, being outside the text fold, is always visible.
44
45
 
45
46
  Mentions ride in the **top-level** ``text`` (mentions inside attachments do
46
47
  not reliably notify on Slack). A ``dashboard_url`` makes the attachment
@@ -55,10 +56,16 @@ class WebhookChannel(BaseAlertChannel):
55
56
  "text": "<!here> ...", # mentions (top level)
56
57
  "channel": "#channel", # optional (Slack)
57
58
  "attachments": [
58
- # base card always visible (never folds; fields don't collapse)
59
- {"color": ..., "title": ..., "fields": [Value, Expected, Links], ...},
60
- # detail card — neutral, foldable text (anomaly/recovery only)
61
- {"text": "<verbose tail>", "mrkdwn_in": ["text"]},
59
+ # one colored card; the long body folds behind "Show more",
60
+ # the footer (brand + logo) stays visible below the fold.
61
+ {
62
+ "color": ...,
63
+ "title": ...,
64
+ "text": "<lead + Rule + value/expected + links + tail>",
65
+ "footer": "detectkit · <project>",
66
+ "footer_icon": "https://.../bot-icon.png",
67
+ "mrkdwn_in": ["text"],
68
+ },
62
69
  ]
63
70
  }
64
71
 
@@ -134,8 +141,9 @@ class WebhookChannel(BaseAlertChannel):
134
141
  Args:
135
142
  alert_data: Alert data to send
136
143
  template: Optional custom message template. When given, the
137
- attachment carries the formatted template as a single text
138
- blob (no fields grid); otherwise the rich default is built.
144
+ attachment carries the formatted template verbatim as the body
145
+ text (in place of the structured lead/Value/tail sections);
146
+ otherwise the rich default is built.
139
147
 
140
148
  Returns:
141
149
  True if sent successfully, False otherwise
@@ -199,16 +207,18 @@ class WebhookChannel(BaseAlertChannel):
199
207
  else:
200
208
  attachments = self._build_rich_attachments(alert_data, ctx, color, title)
201
209
 
202
- # Dashboard link → clickable title on the base (first) attachment.
210
+ # Dashboard link → clickable title on the attachment.
203
211
  if alert_data.dashboard_url:
204
212
  attachments[0]["title_link"] = alert_data.dashboard_url
205
213
 
206
- # Brand the LAST attachment's footer (reliable on Slack even when
207
- # top-level username/icon are locked to the app install) so the brand
208
- # line sits at the bottom of the whole message — below the folded detail
209
- # card when one is present. Pair the brand name with the project name
210
- # when set ("detectkit · my_project") so two projects posting to the same
211
- # channel stay distinguishable even past the title.
214
+ # Brand the (single) attachment's footer (reliable on Slack even when
215
+ # top-level username/icon are locked to the app install). Mattermost and
216
+ # Slack render an attachment's footer *outside* the "Show more" text
217
+ # fold, so the brand line and its logo (``footer_icon``) stays
218
+ # visible at the bottom of the message even when the body is collapsed.
219
+ # Pair the brand name with the project name when set
220
+ # ("detectkit · my_project") so two projects posting to the same channel
221
+ # stay distinguishable even past the title.
212
222
  footer = self.username or BRAND_USERNAME
213
223
  if alert_data.project_name:
214
224
  footer = f"{footer} · {alert_data.project_name}"
@@ -252,114 +262,131 @@ class WebhookChannel(BaseAlertChannel):
252
262
  color: str,
253
263
  title: str,
254
264
  ) -> list[dict[str, Any]]:
255
- """Build the default attachment(s) for *alert_data*.
256
-
257
- Returns a **base card** (always visible) and, for anomaly/recovery, a
258
- neutral **detail card** whose long ``text`` block the chat client folds
259
- behind "Show more". Slack and Mattermost collapse only an attachment's
260
- ``text`` (never the ``fields`` grid), so the base keeps the value, the
261
- expected band and the links permanently in view while the verbose tail
262
- (quorum, severity, the anomalous span, detectors, parameters) rides in
263
- the foldable detail card. No-data / error are short, so they render as a
264
- single base card. Both cards render from one payload on Slack and
265
- Mattermost.
265
+ """Build the default **single** attachment for *alert_data*.
266
+
267
+ Everything rides in one status-colored attachment whose body is a single
268
+ markdown ``text`` block, ordered most-important-first: the lead + Rule
269
+ chip, then value / expected, the action links, and finally the verbose
270
+ evidence tail (quorum, severity, the anomalous span, detectors,
271
+ parameters). Mattermost wraps only an attachment's ``text`` in its
272
+ "Show more" fold (``maxHeight`` 200px) and renders the ``title``, color
273
+ bar and ``footer`` outside it; Slack folds a long ``text`` too. So a long
274
+ anomaly collapses its tail behind "Show more" exactly the one-block,
275
+ one-color, foldable layout of a reference AlertManager alert — while the
276
+ colored bar, the clickable title and the branded footer/logo (attached in
277
+ :meth:`build_payload`) stay in view even when collapsed. No-data / error
278
+ stay short, single un-folded cards; a long anomaly (or a full recovery
279
+ timeline) folds its tail.
266
280
  """
267
281
  kind = self.status_kind(alert_data)
268
- base_fields: list[dict[str, Any]] = []
269
- detail_lines: list[str] = []
270
-
271
- def short(name: str, value: str) -> None:
272
- """Add a 2-col field to the always-visible base card."""
273
- if value:
274
- base_fields.append({"title": name, "value": value, "short": True})
275
-
276
- def full(name: str, value: str) -> None:
277
- """Add a full-width field to the always-visible base card."""
278
- if value:
279
- base_fields.append({"title": name, "value": value, "short": False})
280
-
281
- def detail(name: str, value: str) -> None:
282
- """Add one line to the foldable detail card: bold label + value."""
283
- if value:
284
- detail_lines.append(f"{self._bold(name)} {value}")
285
282
 
286
283
  def code(s: str) -> str:
287
284
  return f"`{s}`" if s else ""
288
285
 
286
+ def line(name: str, value: str) -> str:
287
+ """One ``bold label + value`` body line (empty value → dropped)."""
288
+ return f"{self._bold(name)} {value}" if value else ""
289
+
289
290
  # The configured firing rule, set apart as a bold "Rule" label + an
290
291
  # inline-code chip so it reads as "this is the config that fired" at a
291
292
  # glance. Backticks render identically on Slack and Mattermost; the bold
292
- # label is platform-aware (see ``_bold``). Stays in the always-visible
293
- # base lead.
293
+ # label is platform-aware (see ``_bold``). Leads the body, above the fold.
294
294
  rule_chip = f"{self._bold('Rule')} " + code(
295
295
  f"min_detectors={ctx['min_detectors']} · "
296
296
  f"direction={ctx['direction_policy']} · "
297
297
  f"consecutive={ctx['consecutive_required']}"
298
298
  )
299
299
 
300
+ # Links — compact clickable labels (never raw URL strings: a Grafana URL
301
+ # can be a paragraph long once it carries variables), rendered in the
302
+ # platform's link syntax and joined by " · ". Kept high in the body so
303
+ # they stay above the fold and actionable at a glance. (The title is also
304
+ # a clickable dashboard link.)
305
+ link_parts: list[str] = []
306
+ if alert_data.dashboard_url:
307
+ link_parts.append(self._link_markup(alert_data.dashboard_url, "Dashboard"))
308
+ for label, url in alert_data.links.items():
309
+ link_parts.append(self._link_markup(url, label))
310
+ if ctx["help_url"]:
311
+ link_parts.append(self._link_markup(ctx["help_url"], ctx["help_label"]))
312
+ links_line = line("Links", " · ".join(link_parts)) if link_parts else ""
313
+
314
+ # The body is built as sections joined by a blank line; the verbose tail
315
+ # is last so the platform fold hides it first.
316
+ sections: list[list[str]] = []
300
317
  if kind == "anomaly":
301
- # Base (always visible): the lead (how long it's been going on), the
302
- # Rule chip, and the value vs the expected band.
303
- lead = f"{ctx['anomaly_lead']}\n{rule_chip}"
304
- short("Value", code(ctx["value_display"]))
305
- short("Expected", code(ctx["expected_range"]))
306
- # Detail (folds): quorum, severity, the span, detectors + params.
307
- detail(
308
- "Quorum",
309
- f"{ctx['detector_count']}/{ctx['min_detectors']} · {ctx['direction']}",
318
+ sections.append([ctx["anomaly_lead"], rule_chip])
319
+ sections.append(
320
+ [
321
+ line("Value", code(ctx["value_display"])),
322
+ line("Expected", code(ctx["expected_range"])),
323
+ links_line,
324
+ ]
310
325
  )
311
- detail("Severity", f"{alert_data.severity:.2f}")
326
+ tail = [
327
+ line(
328
+ "Quorum",
329
+ f"{ctx['detector_count']}/{ctx['min_detectors']} · {ctx['direction']}",
330
+ ),
331
+ line("Severity", f"{alert_data.severity:.2f}"),
332
+ ]
312
333
  if ctx["started_display"]:
313
- detail("Anomaly began", ctx["started_display"])
314
- detail("Latest reading", ctx["timestamp"])
334
+ tail.append(line("Anomaly began", ctx["started_display"]))
335
+ tail.append(line("Latest reading", ctx["timestamp"]))
315
336
  else:
316
- detail("Detected at", ctx["timestamp"])
317
- detail("Detectors", code(ctx["detector_name"]))
337
+ tail.append(line("Detected at", ctx["timestamp"]))
338
+ tail.append(line("Detectors", code(ctx["detector_name"])))
318
339
  if ctx["detector_params"]:
319
340
  # Fenced block on its own lines so it renders as a code block (and
320
341
  # adds line breaks that help trip the platform's fold).
321
- detail_lines.append(
322
- f"{self._bold('Parameters')}\n```\n{ctx['detector_params']}\n```"
323
- )
342
+ tail.append(f"{self._bold('Parameters')}\n```\n{ctx['detector_params']}\n```")
343
+ sections.append(tail)
324
344
  elif kind == "recovery":
325
- lead = f"{ctx['recovery_lead']}\n{rule_chip}"
326
- short("Value", code(ctx["value_display"]))
327
- short("Expected", code(ctx["expected_range"]))
328
- # Detail (folds): the incident timeline (onset → fired → recovered)
329
- # and the detectors. ``detail`` skips the fired line when unknown.
345
+ sections.append([ctx["recovery_lead"], rule_chip])
346
+ sections.append(
347
+ [
348
+ line("Value", code(ctx["value_display"])),
349
+ line("Expected", code(ctx["expected_range"])),
350
+ links_line,
351
+ ]
352
+ )
353
+ # The incident timeline (onset → fired → recovered) + detectors;
354
+ # ``line`` drops the fired entry when unknown.
330
355
  if ctx["started_display"]:
331
- detail("Anomaly began", ctx["started_display"])
332
- detail("Alert fired", ctx["fired_display"])
333
- detail("Recovered", ctx["timestamp"])
356
+ tail = [
357
+ line("Anomaly began", ctx["started_display"]),
358
+ line("Alert fired", ctx["fired_display"]),
359
+ line("Recovered", ctx["timestamp"]),
360
+ ]
334
361
  else:
335
- detail("Cleared at", ctx["timestamp"])
336
- detail("Detectors", code(ctx["detector_name"]))
362
+ tail = [line("Cleared at", ctx["timestamp"])]
363
+ tail.append(line("Detectors", code(ctx["detector_name"])))
364
+ sections.append(tail)
337
365
  elif kind == "no_data":
338
- lead = "Query returned no datapoint for the latest expected interval."
339
- full("Expected at", ctx["timestamp"])
340
- short("Expected", code(ctx["expected_range"]))
366
+ sections.append(["Query returned no datapoint for the latest expected interval."])
367
+ sections.append(
368
+ [
369
+ line("Expected at", ctx["timestamp"]),
370
+ line("Expected", code(ctx["expected_range"])),
371
+ links_line,
372
+ ]
373
+ )
341
374
  else: # error
342
- lead = "The detectkit pipeline failed for this metric."
343
- full("Detected at", ctx["timestamp"])
344
375
  err = f"{ctx['error_type']}: {ctx['error_message']}".strip(": ")
345
- full("Error", code(err))
346
-
347
- # Links — always visible in the base card. The dashboard / "how to read
348
- # this alert" links are actionable, so they never fold. Every entry is a
349
- # compact clickable label, never a raw URL string (a Grafana URL can be a
350
- # paragraph long once it carries variables; nobody should read that in an
351
- # alert), rendered in the platform's link syntax and joined by " · " on a
352
- # single line to stay compact. (The title is also a clickable dashboard
353
- # link.)
354
- link_parts = []
355
- if alert_data.dashboard_url:
356
- link_parts.append(self._link_markup(alert_data.dashboard_url, "Dashboard"))
357
- for label, url in alert_data.links.items():
358
- link_parts.append(self._link_markup(url, label))
359
- if ctx["help_url"]:
360
- link_parts.append(self._link_markup(ctx["help_url"], ctx["help_label"]))
361
- if link_parts:
362
- full("Links", " · ".join(link_parts))
376
+ sections.append(["The detectkit pipeline failed for this metric."])
377
+ sections.append(
378
+ [
379
+ line("Detected at", ctx["timestamp"]),
380
+ line("Error", code(err)),
381
+ links_line,
382
+ ]
383
+ )
384
+
385
+ text = "\n\n".join(
386
+ "\n".join(entry for entry in section if entry)
387
+ for section in sections
388
+ if any(entry for entry in section)
389
+ )
363
390
 
364
391
  # A plain-text one-liner for notification previews / unsupported clients.
365
392
  if kind == "no_data":
@@ -372,26 +399,15 @@ class WebhookChannel(BaseAlertChannel):
372
399
  f"(expected {ctx['expected_range']}) at {ctx['timestamp']}"
373
400
  )
374
401
 
375
- base: dict[str, Any] = {
376
- "fallback": fallback,
377
- "color": color,
378
- "title": title,
379
- "text": lead,
380
- "fields": base_fields,
381
- "mrkdwn_in": ["text", "fields"],
382
- }
383
- attachments: list[dict[str, Any]] = [base]
384
- # Detail card: neutral (no color bar) so it reads as a continuation of
385
- # the base rather than a second alert, with the verbose tail in one
386
- # foldable ``text`` block.
387
- if detail_lines:
388
- attachments.append(
389
- {
390
- "text": "\n".join(detail_lines),
391
- "mrkdwn_in": ["text"],
392
- }
393
- )
394
- return attachments
402
+ return [
403
+ {
404
+ "fallback": fallback,
405
+ "color": color,
406
+ "title": title,
407
+ "text": text,
408
+ "mrkdwn_in": ["text"],
409
+ }
410
+ ]
395
411
 
396
412
  def _bold(self, text: str) -> str:
397
413
  """Render *text* bold in the target platform's markdown.
@@ -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
 
@@ -167,7 +167,7 @@ config needed. Control it project-wide with `alert_help_url` in
167
167
  Per-channel rendering (defaults only; the resolved help URL is rendered per channel as follows):
168
168
 
169
169
  - **Slack / Mattermost / generic webhook** — a clickable `How to read this alert`
170
- label in the compact `Links` field (alongside `Dashboard` + any extra links),
170
+ label in the compact `Links` line (alongside `Dashboard` + any extra links),
171
171
  never a raw URL. Rendered in the platform's link syntax (Slack `<url|label>`,
172
172
  Mattermost/generic markdown links) so a long dashboard URL stays hidden behind
173
173
  its label.
@@ -191,25 +191,25 @@ leads with a colored **status circle** — 🔴 anomaly, 🟢 recovery, 🟡 no-
191
191
  **project name** as a `[name] ` prefix (from `detectkit_project.yml`) — see
192
192
  [Project label](#project-label-multi-project-channels) below.
193
193
 
194
- - **Slack / Mattermost / generic webhook** — an anomaly/recovery renders as
195
- **two stacked attachments** so long alerts fold in the channel. Both platforms
196
- collapse only an attachment's `text` block behind a "Show more" toggle (Slack
197
- above 700 chars / 5 line breaks; Mattermost above ~200px) and never collapse
198
- the `fields` grid, so the message splits into:
199
- - an always-visible **base card** status-colored accent bar, a clickable
200
- title (the metric; links to `dashboard_url` when set), a short markdown lead
201
- (the duration sentence see "Incident timing" below) with the **Rule** chip
202
- beneath it, and a compact fields grid kept to **Value / Expected** plus an
203
- always-visible compact **Links** field (dashboard + extra links + the "how to
204
- read this alert" guide as clickable labels);
205
- - a neutral, foldable **detail card** the verbose tail as one markdown
206
- `text` block: Quorum / Severity / the anomalous span (Anomaly began → Latest
207
- reading; began fired recovered on recovery) / Detectors / Parameters.
208
-
209
- No-data / error stay a single base card. The branded footer + footer icon ride
210
- on the **last** attachment. @mentions ride in the **top-level** message text so
211
- they notify. A custom `template` instead renders as a single plain text-only
212
- attachment (color/title/branding kept, no fields grid, no fold split).
194
+ - **Slack / Mattermost / generic webhook** — an alert renders as **one
195
+ status-colored attachment** whose whole body is a single markdown `text` block,
196
+ ordered **most-important-first** so a long alert folds its tail behind a
197
+ **"Show more"** toggle one block, one color, just like a reference
198
+ AlertManager alert. The body order: the clickable title (the metric; links to
199
+ `dashboard_url` when set), then the markdown lead (the duration sentence — see
200
+ "Incident timing" below) with the **Rule** chip beneath it, **Value /
201
+ Expected**, the compact **Links** line (dashboard + extra links + the "how to
202
+ read this alert" guide as clickable labels, never raw URLs), and finally the
203
+ verbose tail (Quorum / Severity / the anomalous span Anomaly began → Latest
204
+ reading; began fired recovered on recovery — / Detectors / Parameters).
205
+ Both clients fold **only** the `text` (Slack above 700 chars / 5 line breaks;
206
+ Mattermost above ~200px) and render the title, the color bar and the **footer**
207
+ *outside* the fold so the branded **footer + footer icon (the logo)** stays
208
+ visible even when the body is collapsed. No-data / error stay short, single
209
+ un-folded cards; a long anomaly (or a full recovery timeline) folds its tail.
210
+ @mentions ride in the **top-level** message text so they notify. A custom
211
+ `template` renders as a single plain text-only attachment (the raw template
212
+ replaces the structured lead/Value/tail sections; color/title/branding kept).
213
213
  - **Telegram** — default `parse_mode` is now **HTML**. The default message is
214
214
  structured and HTML-escaped: a colored status dot (red anomaly / green
215
215
  recovery / yellow no-data / blue error), a bold headline, the lead + rule, then
@@ -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`)
@@ -193,6 +195,44 @@ actually delete.
193
195
  unless `-y/--yes`; refuses to run if the project defines no metrics or configs
194
196
  fail to parse (so a wrong directory can't wipe valid data).
195
197
 
198
+ ## OSI interop (`dtk osi`)
199
+
200
+ `dtk osi` converts between [Open Semantic Interchange](https://github.com/open-semantic-interchange/OSI)
201
+ (OSI) semantic models and native detectkit metrics. It's a **separate, additive**
202
+ layer: it never runs the pipeline, takes no lock, and the converter package
203
+ (`detectkit/semantic/`) is not imported by load/detect/alert — so it can't affect
204
+ a running project. OSI is treated as an *interchange* format (define the KPI once,
205
+ consume in BI + AI), not an execution engine. OSI adoption is still early, so the
206
+ broadly-useful piece today is `ai_context` (see the metrics rule) — the converters
207
+ are a forward bridge for teams that already run a governed semantic layer.
208
+
209
+ - `dtk osi import <model.osi.yml> --metric <name> --interval <grain>` — the
210
+ "enhanced init": resolve one OSI metric and **scaffold a normal native metric**
211
+ (SQL `query`, interval, a starter detector, the metric's `ai_context`). Review
212
+ it, then commit like any hand-written metric. Targets:
213
+ - `--target clickhouse` (default) — a direct `toStartOfInterval(...) GROUP BY`
214
+ query from the dataset's physical `source` (ANSI→ClickHouse via **sqlglot**,
215
+ the optional `[osi]` extra: `pip install 'detectkit[osi]'`).
216
+ - `--target cube --cube <name> --time-field <dim>` — a Cube **SQL-API**
217
+ `MEASURE(...)` query, so the metric runs through Cube and the alert matches a
218
+ Cube dashboard's number by construction. Point its `profile` at a Postgres
219
+ connection on Cube's SQL port.
220
+ - Only provably per-bucket-additive measures compile (`SUM`/`COUNT`/`COUNT(DISTINCT)`/
221
+ `AVG`/`MIN`/`MAX` + ratios like `SUM(x)/NULLIF(COUNT(DISTINCT y),0)`). Window
222
+ functions / non-aggregates / unknown aggregates are **refused** with a message
223
+ to use `query_file:` — never a plausible-but-wrong series.
224
+ - `dtk osi compile <model> --metric <name> --interval <g>` prints just the SQL
225
+ (for review). `dtk osi export [--select <sel>]` writes an OSI fragment carrying
226
+ a lossless snapshot of the config in `custom_extensions[detectkit]` + the
227
+ `ai_context` — a **one-way carrier** (`import` does not reconstruct from it; the
228
+ metric YAML stays the source of truth).
229
+
230
+ ```bash
231
+ dtk osi compile model.osi.yml -m total_sales -i 1h # preview the SQL
232
+ dtk osi import model.osi.yml -m total_sales -i 1h -o metrics/ # scaffold a metric
233
+ dtk osi export -o semantic/detectkit.osi.yml # publish back to OSI
234
+ ```
235
+
196
236
  ## Common workflows
197
237
 
198
238
  ```bash