nthlayer-workers 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. nthlayer_workers/__init__.py +5 -0
  2. nthlayer_workers/cli.py +234 -0
  3. nthlayer_workers/correlate/__init__.py +1 -0
  4. nthlayer_workers/correlate/cli.py +847 -0
  5. nthlayer_workers/correlate/config.py +111 -0
  6. nthlayer_workers/correlate/correlation/__init__.py +1 -0
  7. nthlayer_workers/correlate/correlation/changes.py +87 -0
  8. nthlayer_workers/correlate/correlation/dedup.py +62 -0
  9. nthlayer_workers/correlate/correlation/engine.py +244 -0
  10. nthlayer_workers/correlate/correlation/temporal.py +79 -0
  11. nthlayer_workers/correlate/correlation/topology.py +104 -0
  12. nthlayer_workers/correlate/ingestion/__init__.py +1 -0
  13. nthlayer_workers/correlate/ingestion/protocol.py +10 -0
  14. nthlayer_workers/correlate/ingestion/severity.py +18 -0
  15. nthlayer_workers/correlate/ingestion/webhook.py +197 -0
  16. nthlayer_workers/correlate/notifications.py +85 -0
  17. nthlayer_workers/correlate/prometheus.py +234 -0
  18. nthlayer_workers/correlate/reasoning.py +375 -0
  19. nthlayer_workers/correlate/session.py +189 -0
  20. nthlayer_workers/correlate/snapshot/__init__.py +1 -0
  21. nthlayer_workers/correlate/snapshot/generator.py +170 -0
  22. nthlayer_workers/correlate/snapshot/model.py +177 -0
  23. nthlayer_workers/correlate/snapshot/token.py +14 -0
  24. nthlayer_workers/correlate/state.py +88 -0
  25. nthlayer_workers/correlate/store/__init__.py +5 -0
  26. nthlayer_workers/correlate/store/protocol.py +48 -0
  27. nthlayer_workers/correlate/store/sqlite.py +443 -0
  28. nthlayer_workers/correlate/summary.py +180 -0
  29. nthlayer_workers/correlate/traces/__init__.py +1 -0
  30. nthlayer_workers/correlate/traces/protocol.py +120 -0
  31. nthlayer_workers/correlate/traces/tempo.py +667 -0
  32. nthlayer_workers/correlate/traces/topology.py +39 -0
  33. nthlayer_workers/correlate/types.py +77 -0
  34. nthlayer_workers/correlate/worker.py +630 -0
  35. nthlayer_workers/learn/__init__.py +5 -0
  36. nthlayer_workers/learn/__main__.py +5 -0
  37. nthlayer_workers/learn/cli.py +164 -0
  38. nthlayer_workers/learn/retrospective.py +381 -0
  39. nthlayer_workers/learn/trends.py +102 -0
  40. nthlayer_workers/learn/worker.py +366 -0
  41. nthlayer_workers/measure/__init__.py +3 -0
  42. nthlayer_workers/measure/__main__.py +5 -0
  43. nthlayer_workers/measure/_parsing.py +15 -0
  44. nthlayer_workers/measure/adapters/__init__.py +0 -0
  45. nthlayer_workers/measure/adapters/_util.py +24 -0
  46. nthlayer_workers/measure/adapters/devin.py +119 -0
  47. nthlayer_workers/measure/adapters/gastown.py +88 -0
  48. nthlayer_workers/measure/adapters/prometheus.py +277 -0
  49. nthlayer_workers/measure/adapters/protocol.py +20 -0
  50. nthlayer_workers/measure/adapters/webhook.py +161 -0
  51. nthlayer_workers/measure/api/__init__.py +0 -0
  52. nthlayer_workers/measure/api/normalise.py +50 -0
  53. nthlayer_workers/measure/api/queue.py +243 -0
  54. nthlayer_workers/measure/api/response.py +51 -0
  55. nthlayer_workers/measure/api/server.py +504 -0
  56. nthlayer_workers/measure/calibration/__init__.py +0 -0
  57. nthlayer_workers/measure/calibration/loop.py +62 -0
  58. nthlayer_workers/measure/calibration/slos.py +212 -0
  59. nthlayer_workers/measure/calibration/verdict_calibration.py +31 -0
  60. nthlayer_workers/measure/cli.py +753 -0
  61. nthlayer_workers/measure/config.py +191 -0
  62. nthlayer_workers/measure/detection/__init__.py +6 -0
  63. nthlayer_workers/measure/detection/detector.py +82 -0
  64. nthlayer_workers/measure/detection/protocol.py +29 -0
  65. nthlayer_workers/measure/governance/__init__.py +0 -0
  66. nthlayer_workers/measure/governance/engine.py +163 -0
  67. nthlayer_workers/measure/manifest.py +77 -0
  68. nthlayer_workers/measure/notifications.py +53 -0
  69. nthlayer_workers/measure/pipeline/__init__.py +0 -0
  70. nthlayer_workers/measure/pipeline/evaluator.py +155 -0
  71. nthlayer_workers/measure/pipeline/router.py +160 -0
  72. nthlayer_workers/measure/store/__init__.py +0 -0
  73. nthlayer_workers/measure/store/protocol.py +38 -0
  74. nthlayer_workers/measure/store/sqlite.py +276 -0
  75. nthlayer_workers/measure/telemetry.py +116 -0
  76. nthlayer_workers/measure/tiering/__init__.py +0 -0
  77. nthlayer_workers/measure/tiering/classifier.py +58 -0
  78. nthlayer_workers/measure/tiering/promotion.py +118 -0
  79. nthlayer_workers/measure/trends/__init__.py +0 -0
  80. nthlayer_workers/measure/trends/tracker.py +72 -0
  81. nthlayer_workers/measure/types.py +75 -0
  82. nthlayer_workers/measure/worker.py +439 -0
  83. nthlayer_workers/observe/__init__.py +25 -0
  84. nthlayer_workers/observe/__main__.py +5 -0
  85. nthlayer_workers/observe/api/__init__.py +1 -0
  86. nthlayer_workers/observe/assessment.py +95 -0
  87. nthlayer_workers/observe/cli.py +737 -0
  88. nthlayer_workers/observe/config.py +11 -0
  89. nthlayer_workers/observe/db/__init__.py +1 -0
  90. nthlayer_workers/observe/decision_records.py +220 -0
  91. nthlayer_workers/observe/dependencies/__init__.py +18 -0
  92. nthlayer_workers/observe/dependencies/discovery.py +294 -0
  93. nthlayer_workers/observe/dependencies/providers/__init__.py +48 -0
  94. nthlayer_workers/observe/dependencies/providers/backstage.py +467 -0
  95. nthlayer_workers/observe/dependencies/providers/base.py +76 -0
  96. nthlayer_workers/observe/dependencies/providers/consul.py +518 -0
  97. nthlayer_workers/observe/dependencies/providers/etcd.py +360 -0
  98. nthlayer_workers/observe/dependencies/providers/kubernetes.py +682 -0
  99. nthlayer_workers/observe/dependencies/providers/prometheus.py +368 -0
  100. nthlayer_workers/observe/dependencies/providers/zookeeper.py +399 -0
  101. nthlayer_workers/observe/deployments/__init__.py +1 -0
  102. nthlayer_workers/observe/discovery/__init__.py +14 -0
  103. nthlayer_workers/observe/discovery/classifier.py +66 -0
  104. nthlayer_workers/observe/discovery/client.py +189 -0
  105. nthlayer_workers/observe/discovery/models.py +53 -0
  106. nthlayer_workers/observe/drift/__init__.py +26 -0
  107. nthlayer_workers/observe/drift/analyzer.py +383 -0
  108. nthlayer_workers/observe/drift/models.py +174 -0
  109. nthlayer_workers/observe/drift/patterns.py +88 -0
  110. nthlayer_workers/observe/explanation.py +118 -0
  111. nthlayer_workers/observe/gate/__init__.py +39 -0
  112. nthlayer_workers/observe/gate/conditions.py +92 -0
  113. nthlayer_workers/observe/gate/correlator.py +154 -0
  114. nthlayer_workers/observe/gate/evaluator.py +192 -0
  115. nthlayer_workers/observe/gate/policies.py +226 -0
  116. nthlayer_workers/observe/gate_adapter.py +40 -0
  117. nthlayer_workers/observe/incident.py +36 -0
  118. nthlayer_workers/observe/portfolio/__init__.py +17 -0
  119. nthlayer_workers/observe/portfolio/aggregator.py +168 -0
  120. nthlayer_workers/observe/portfolio/scorer.py +13 -0
  121. nthlayer_workers/observe/slo/__init__.py +19 -0
  122. nthlayer_workers/observe/slo/collector.py +235 -0
  123. nthlayer_workers/observe/slo/spec_loader.py +40 -0
  124. nthlayer_workers/observe/sqlite_store.py +152 -0
  125. nthlayer_workers/observe/store.py +92 -0
  126. nthlayer_workers/observe/verification/__init__.py +22 -0
  127. nthlayer_workers/observe/verification/exporter_guidance.py +146 -0
  128. nthlayer_workers/observe/verification/extractor.py +127 -0
  129. nthlayer_workers/observe/verification/models.py +101 -0
  130. nthlayer_workers/observe/verification/verifier.py +111 -0
  131. nthlayer_workers/observe/worker.py +332 -0
  132. nthlayer_workers/respond/__init__.py +2 -0
  133. nthlayer_workers/respond/__main__.py +4 -0
  134. nthlayer_workers/respond/agents/__init__.py +0 -0
  135. nthlayer_workers/respond/agents/base.py +556 -0
  136. nthlayer_workers/respond/agents/communication.py +115 -0
  137. nthlayer_workers/respond/agents/investigation.py +124 -0
  138. nthlayer_workers/respond/agents/remediation.py +219 -0
  139. nthlayer_workers/respond/agents/triage.py +132 -0
  140. nthlayer_workers/respond/cli.py +772 -0
  141. nthlayer_workers/respond/config.py +135 -0
  142. nthlayer_workers/respond/context_store.py +256 -0
  143. nthlayer_workers/respond/coordinator.py +487 -0
  144. nthlayer_workers/respond/metrics.py +104 -0
  145. nthlayer_workers/respond/notification_backends/__init__.py +1 -0
  146. nthlayer_workers/respond/notification_backends/ntfy_backend.py +158 -0
  147. nthlayer_workers/respond/notification_backends/protocol.py +59 -0
  148. nthlayer_workers/respond/notification_backends/slack_backend.py +203 -0
  149. nthlayer_workers/respond/notification_backends/stdout_backend.py +56 -0
  150. nthlayer_workers/respond/notifications.py +247 -0
  151. nthlayer_workers/respond/oncall/__init__.py +1 -0
  152. nthlayer_workers/respond/oncall/escalation.py +103 -0
  153. nthlayer_workers/respond/oncall/runner.py +193 -0
  154. nthlayer_workers/respond/oncall/schedule.py +243 -0
  155. nthlayer_workers/respond/safe_actions/__init__.py +0 -0
  156. nthlayer_workers/respond/safe_actions/actions.py +139 -0
  157. nthlayer_workers/respond/safe_actions/registry.py +171 -0
  158. nthlayer_workers/respond/safe_actions/webhook.py +194 -0
  159. nthlayer_workers/respond/server.py +357 -0
  160. nthlayer_workers/respond/sre/__init__.py +1 -0
  161. nthlayer_workers/respond/sre/brief.py +175 -0
  162. nthlayer_workers/respond/sre/delegation.py +101 -0
  163. nthlayer_workers/respond/sre/post_incident.py +146 -0
  164. nthlayer_workers/respond/sre/shift_report.py +129 -0
  165. nthlayer_workers/respond/sre/suppression.py +91 -0
  166. nthlayer_workers/respond/types.py +109 -0
  167. nthlayer_workers/respond/verdict_submission.py +56 -0
  168. nthlayer_workers/respond/worker.py +533 -0
  169. nthlayer_workers/respond/worker_helpers.py +140 -0
  170. nthlayer_workers/runner.py +198 -0
  171. nthlayer_workers-1.0.0.dist-info/METADATA +19 -0
  172. nthlayer_workers-1.0.0.dist-info/RECORD +175 -0
  173. nthlayer_workers-1.0.0.dist-info/WHEEL +5 -0
  174. nthlayer_workers-1.0.0.dist-info/entry_points.txt +2 -0
  175. nthlayer_workers-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,175 @@
1
+ """Paging brief — what the SRE sees when paged.
2
+
3
+ Queries the verdict store for triage, correlation, and remediation
4
+ verdicts linked to a specific incident, then renders a concise brief
5
+ answering three questions: what's broken, why, and what can I do about it.
6
+
7
+ Every field comes from a verdict field. No model call.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ import structlog
16
+
17
+ logger = structlog.get_logger(__name__)
18
+
19
+ SEVERITY_EMOJI = {1: "\U0001f534", 2: "\U0001f7e0", 3: "\U0001f7e1", 4: "\U0001f535"}
20
+ SEVERITY_LABEL = {1: "P1", 2: "P2", 3: "P3", 4: "P4"}
21
+
22
+
23
+ @dataclass
24
+ class PagingBrief:
25
+ """Structured paging brief built from verdicts."""
26
+
27
+ incident_id: str
28
+ service: str
29
+ severity: int
30
+ summary: str
31
+ likely_cause: str | None = None
32
+ cause_confidence: float | None = None
33
+ blast_radius: list[str] = field(default_factory=list)
34
+ recommended_action: str | None = None
35
+
36
+
37
+ def build_paging_brief(incident_id: str, verdict_store: Any) -> PagingBrief:
38
+ """Build a paging brief from verdict store queries.
39
+
40
+ Uses lineage walking from the triage verdict to find related
41
+ correlation and remediation verdicts for this specific incident.
42
+ Falls back to filtered queries when lineage is unavailable.
43
+ """
44
+ from nthlayer_common.verdicts import VerdictFilter
45
+
46
+ # Find triage verdict for this incident by querying and filtering
47
+ triage_verdicts = verdict_store.query(
48
+ VerdictFilter(
49
+ producer_system="nthlayer-respond",
50
+ subject_type="triage",
51
+ limit=20,
52
+ )
53
+ )
54
+ # Filter to this incident by checking subject.ref or metadata
55
+ triage = None
56
+ for v in triage_verdicts:
57
+ ref = getattr(v.subject, "ref", None) or getattr(v.subject, "service", None)
58
+ if ref:
59
+ triage = v
60
+ break
61
+ if not triage and triage_verdicts:
62
+ triage = triage_verdicts[0]
63
+
64
+ # Walk lineage from triage to find related verdicts
65
+ related = []
66
+ if triage:
67
+ try:
68
+ related = verdict_store.by_lineage(triage.id, direction="both")
69
+ except Exception:
70
+ pass
71
+
72
+ # Classify related verdicts
73
+ correlation_verdicts = [
74
+ v for v in related
75
+ if v.producer.system == "nthlayer-correlate"
76
+ and v.subject.type == "correlation"
77
+ ]
78
+ remediation_verdicts = [
79
+ v for v in related
80
+ if v.producer.system == "nthlayer-respond"
81
+ and v.subject.type == "remediation"
82
+ ]
83
+
84
+ # Fallback: query directly if lineage didn't find them
85
+ if not correlation_verdicts:
86
+ correlation_verdicts = verdict_store.query(
87
+ VerdictFilter(
88
+ producer_system="nthlayer-correlate",
89
+ subject_type="correlation",
90
+ limit=5,
91
+ )
92
+ )
93
+ if not remediation_verdicts:
94
+ remediation_verdicts = verdict_store.query(
95
+ VerdictFilter(
96
+ producer_system="nthlayer-respond",
97
+ subject_type="remediation",
98
+ limit=5,
99
+ )
100
+ )
101
+
102
+ # Extract triage fields
103
+ service = "unknown"
104
+ severity = 3
105
+ blast_radius: list[str] = []
106
+ summary = "No triage available"
107
+ if triage:
108
+ service = getattr(triage.subject, "ref", None) or getattr(triage.subject, "service", "unknown")
109
+ custom = triage.metadata.custom
110
+ severity = custom.get("severity", 3)
111
+ if severity is None:
112
+ severity = 3
113
+ logger.warning("paging_brief_severity_missing", incident_id=incident_id)
114
+ blast_radius = custom.get("blast_radius", [])
115
+ summary = triage.judgment.reasoning
116
+
117
+ # Extract correlation root cause (highest confidence, skip None)
118
+ likely_cause = None
119
+ cause_confidence = None
120
+ valid_correlations = [
121
+ v for v in correlation_verdicts
122
+ if v.judgment.confidence is not None
123
+ ]
124
+ if valid_correlations:
125
+ top = max(valid_correlations, key=lambda v: v.judgment.confidence)
126
+ likely_cause = top.judgment.reasoning
127
+ cause_confidence = top.judgment.confidence
128
+
129
+ # Extract remediation
130
+ recommended_action = None
131
+ if remediation_verdicts:
132
+ rem_custom = remediation_verdicts[0].metadata.custom
133
+ recommended_action = rem_custom.get("proposed_action")
134
+
135
+ return PagingBrief(
136
+ incident_id=incident_id,
137
+ service=service,
138
+ severity=severity,
139
+ summary=summary,
140
+ likely_cause=likely_cause,
141
+ cause_confidence=cause_confidence,
142
+ blast_radius=blast_radius,
143
+ recommended_action=recommended_action,
144
+ )
145
+
146
+
147
+ def render_brief(brief: PagingBrief) -> str:
148
+ """Render a PagingBrief to a human-readable text string."""
149
+ emoji = SEVERITY_EMOJI.get(brief.severity, "")
150
+ label = SEVERITY_LABEL.get(brief.severity, f"P{brief.severity}")
151
+
152
+ lines = [
153
+ f"{emoji} {label}: {brief.service}",
154
+ "",
155
+ f"What's happening: {brief.summary}",
156
+ ]
157
+
158
+ if brief.likely_cause:
159
+ conf = f" (confidence: {brief.cause_confidence:.2f})" if brief.cause_confidence is not None else ""
160
+ lines.append(f"Likely cause: {brief.likely_cause}{conf}")
161
+ else:
162
+ lines.append("Likely cause: Investigation in progress")
163
+
164
+ if brief.blast_radius:
165
+ lines.append(f"Blast radius: {', '.join(brief.blast_radius)}")
166
+
167
+ if brief.recommended_action:
168
+ lines.append(f"Recommended: {brief.recommended_action}")
169
+
170
+ lines.extend([
171
+ "",
172
+ f"Incident: {brief.incident_id}",
173
+ ])
174
+
175
+ return "\n".join(lines)
@@ -0,0 +1,101 @@
1
+ """Delegation mode — 'I'm busy, handle it'.
2
+
3
+ During a multi-incident situation, the SRE delegates a lower-priority
4
+ incident to autonomous handling. The coordinator continues with
5
+ pre-approved safe actions only. Notifications are suppressed except
6
+ for resolution or escalation.
7
+
8
+ No model call. Delegation is a governance configuration change.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timedelta, timezone
15
+ from enum import Enum
16
+
17
+ import structlog
18
+
19
+ logger = structlog.get_logger(__name__)
20
+
21
+ _DEFAULT_MAX_DURATION = timedelta(hours=2)
22
+
23
+ # Only these event types break through delegation silence
24
+ _NOTIFY_EVENTS = frozenset({"resolution", "escalation"})
25
+
26
+
27
+ class DelegationStatus(Enum):
28
+ """Lifecycle of a delegation."""
29
+
30
+ ACTIVE = "active"
31
+ EXPIRED = "expired"
32
+ ESCALATED = "escalated"
33
+ RESOLVED = "resolved"
34
+
35
+
36
+ @dataclass
37
+ class Delegation:
38
+ """Tracks delegation state for an incident."""
39
+
40
+ incident_id: str
41
+ delegated_by: str
42
+ delegated_at: datetime
43
+ expires_at: datetime
44
+ max_duration: timedelta
45
+ safe_actions_only: bool = True
46
+ status: DelegationStatus = DelegationStatus.ACTIVE
47
+
48
+
49
+ def create_delegation(
50
+ *,
51
+ incident_id: str,
52
+ delegated_by: str,
53
+ safe_actions_only: bool = True,
54
+ max_duration: timedelta = _DEFAULT_MAX_DURATION,
55
+ ) -> Delegation:
56
+ """Create a delegation for an incident.
57
+
58
+ The SRE won't receive updates until resolution or escalation.
59
+ If safe actions are insufficient, the delegation escalates back.
60
+ Auto-expires after ``max_duration``.
61
+ """
62
+ now = datetime.now(timezone.utc)
63
+
64
+ logger.info(
65
+ "delegation_created",
66
+ incident_id=incident_id,
67
+ delegated_by=delegated_by,
68
+ safe_actions_only=safe_actions_only,
69
+ max_duration_hours=max_duration.total_seconds() / 3600,
70
+ )
71
+
72
+ return Delegation(
73
+ incident_id=incident_id,
74
+ delegated_by=delegated_by,
75
+ delegated_at=now,
76
+ expires_at=now + max_duration,
77
+ max_duration=max_duration,
78
+ safe_actions_only=safe_actions_only,
79
+ )
80
+
81
+
82
+ def check_delegation_expired(delegation: Delegation, now: datetime) -> bool:
83
+ """Check if a delegation has expired.
84
+
85
+ Already-resolved or escalated delegations are not considered expired.
86
+ """
87
+ if delegation.status != DelegationStatus.ACTIVE:
88
+ return False
89
+ return now >= delegation.expires_at
90
+
91
+
92
+ def should_notify_delegator(delegation: Delegation, event_type: str) -> bool:
93
+ """Determine if the delegator should be notified for this event.
94
+
95
+ Only resolution and escalation events break through the delegation
96
+ silence while the delegation is active. Inactive delegations
97
+ (resolved, expired, escalated) do not filter notifications.
98
+ """
99
+ if delegation.status != DelegationStatus.ACTIVE:
100
+ return True # No longer delegated — all events pass through
101
+ return event_type in _NOTIFY_EVENTS
@@ -0,0 +1,146 @@
1
+ """Post-incident review — auto-generated from the verdict chain.
2
+
3
+ Reconstructs the incident timeline, classifies what worked vs what
4
+ needs improvement, and shows per-verdict accuracy. Every section
5
+ is built from verdict queries. The optional model call for action
6
+ item suggestions is clearly bounded and off by default.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from typing import Any
14
+
15
+ import structlog
16
+
17
+ logger = structlog.get_logger(__name__)
18
+
19
+ # Agent systems whose verdicts are included in accuracy tracking
20
+ _AGENT_SYSTEMS = {"nthlayer-respond", "nthlayer-correlate", "nthlayer-measure"}
21
+
22
+
23
+ @dataclass
24
+ class TimelineEntry:
25
+ """A single event in the incident timeline."""
26
+
27
+ timestamp: datetime
28
+ source: str
29
+ agent: str
30
+ summary: str
31
+ confidence: float | None = None
32
+
33
+
34
+ @dataclass
35
+ class PostIncidentReview:
36
+ """Structured post-incident review."""
37
+
38
+ incident_id: str
39
+ timeline: list[TimelineEntry] = field(default_factory=list)
40
+ worked: list[str] = field(default_factory=list)
41
+ improve: list[str] = field(default_factory=list)
42
+ accuracy: list[str] = field(default_factory=list)
43
+
44
+
45
+ def build_timeline(verdicts: list[Any]) -> list[TimelineEntry]:
46
+ """Reconstruct the incident timeline from verdict timestamps.
47
+
48
+ Pure query + sort. No model call.
49
+ """
50
+ entries = []
51
+ for v in verdicts:
52
+ entries.append(
53
+ TimelineEntry(
54
+ timestamp=v.timestamp,
55
+ source=v.producer.system,
56
+ agent=v.producer.instance,
57
+ summary=v.subject.summary,
58
+ confidence=v.judgment.confidence,
59
+ )
60
+ )
61
+ entries.sort(key=lambda e: e.timestamp)
62
+ return entries
63
+
64
+
65
+ def build_review_sections(verdicts: list[Any]) -> tuple[list[str], list[str]]:
66
+ """Build 'what worked' and 'what to improve' from verdict outcomes.
67
+
68
+ Confirmed verdicts → worked. Overridden verdicts → improve.
69
+ Pending/expired verdicts are excluded.
70
+ """
71
+ worked = []
72
+ improve = []
73
+
74
+ for v in verdicts:
75
+ status = v.outcome.status
76
+ instance = v.producer.instance
77
+
78
+ if status == "confirmed":
79
+ worked.append(f"{instance}: {v.subject.summary} (confirmed \u2713)")
80
+ elif status == "overridden":
81
+ reason = ""
82
+ if v.outcome.override and v.outcome.override.reasoning:
83
+ reason = f" — {v.outcome.override.reasoning}"
84
+ improve.append(f"{instance}: overridden{reason}")
85
+
86
+ return worked, improve
87
+
88
+
89
+ def build_accuracy_section(verdicts: list[Any]) -> list[str]:
90
+ """Show confirmed/overridden status for each agent verdict.
91
+
92
+ Only includes verdicts from agent systems (nthlayer-respond,
93
+ nthlayer-correlate, nthlayer-measure). Human/manual verdicts excluded.
94
+ """
95
+ lines = []
96
+ for v in verdicts:
97
+ if v.producer.system not in _AGENT_SYSTEMS:
98
+ continue
99
+
100
+ status = v.outcome.status
101
+ instance = v.producer.instance
102
+
103
+ if status == "confirmed":
104
+ lines.append(f" {instance}: confirmed \u2713")
105
+ elif status == "overridden":
106
+ reason = ""
107
+ if v.outcome.override and v.outcome.override.reasoning:
108
+ reason = f" ({v.outcome.override.reasoning})"
109
+ lines.append(f" {instance}: overridden \u2717{reason}")
110
+
111
+ return lines
112
+
113
+
114
+ def render_post_incident(review: PostIncidentReview) -> str:
115
+ """Render a PostIncidentReview to human-readable text."""
116
+ lines = [
117
+ f"Post-Incident Review: {review.incident_id}",
118
+ "Auto-generated from verdict chain.",
119
+ "",
120
+ ]
121
+
122
+ if review.timeline:
123
+ lines.append("Timeline:")
124
+ for entry in review.timeline:
125
+ ts = entry.timestamp.strftime("%H:%M")
126
+ conf = f" ({entry.confidence:.0%})" if entry.confidence is not None else ""
127
+ lines.append(f" {ts} {entry.source}: {entry.summary}{conf}")
128
+ lines.append("")
129
+
130
+ if review.worked:
131
+ lines.append("What worked:")
132
+ for item in review.worked:
133
+ lines.append(f" \u2022 {item}")
134
+ lines.append("")
135
+
136
+ if review.improve:
137
+ lines.append("What to improve:")
138
+ for item in review.improve:
139
+ lines.append(f" \u2022 {item}")
140
+ lines.append("")
141
+
142
+ if review.accuracy:
143
+ lines.append("Verdict accuracy:")
144
+ lines.extend(review.accuracy)
145
+
146
+ return "\n".join(lines)
@@ -0,0 +1,129 @@
1
+ """Shift report — summary of what happened during an on-call shift.
2
+
3
+ At shift start, the SRE receives a summary of incidents, governance
4
+ changes, evaluations, and pending reviews from the verdict store.
5
+ Every field comes from verdict queries. No model call.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+ from typing import Any
13
+
14
+ import structlog
15
+
16
+ logger = structlog.get_logger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class ShiftReport:
21
+ """Structured shift report built from verdict queries."""
22
+
23
+ window_start: datetime
24
+ window_end: datetime
25
+ incidents: list[Any] = field(default_factory=list)
26
+ evaluations_count: int = 0
27
+ pending_reviews: int = 0
28
+
29
+
30
+ def build_shift_report(
31
+ shift_start: datetime,
32
+ shift_end: datetime,
33
+ verdict_store: Any,
34
+ ) -> ShiftReport:
35
+ """Build a shift report from verdict store queries.
36
+
37
+ Queries for incidents (triage + custom incident verdicts),
38
+ evaluations, and pending reviews within the shift window.
39
+ """
40
+ from nthlayer_common.verdicts import VerdictFilter
41
+
42
+ # Triage verdicts in window (real incidents)
43
+ triage_verdicts = verdict_store.query(
44
+ VerdictFilter(
45
+ producer_system="nthlayer-respond",
46
+ subject_type="triage",
47
+ from_time=shift_start,
48
+ to_time=shift_end,
49
+ limit=0,
50
+ )
51
+ )
52
+
53
+ # Custom verdicts with incident_type (incident summaries)
54
+ custom_verdicts = verdict_store.query(
55
+ VerdictFilter(
56
+ subject_type="custom",
57
+ from_time=shift_start,
58
+ to_time=shift_end,
59
+ limit=0,
60
+ )
61
+ )
62
+ incident_summaries = [
63
+ v for v in custom_verdicts
64
+ if v.metadata.custom.get("incident_type") == "incident"
65
+ ]
66
+
67
+ # Combine — triage verdicts are incidents, plus explicit incident summaries
68
+ incidents = triage_verdicts + incident_summaries
69
+
70
+ # Evaluations in window
71
+ evaluations = verdict_store.query(
72
+ VerdictFilter(
73
+ producer_system="nthlayer-measure",
74
+ subject_type="evaluation",
75
+ from_time=shift_start,
76
+ to_time=shift_end,
77
+ limit=100,
78
+ )
79
+ )
80
+
81
+ # Pending reviews (no time filter — all currently pending)
82
+ pending = verdict_store.query(
83
+ VerdictFilter(
84
+ status="pending",
85
+ limit=100,
86
+ )
87
+ )
88
+
89
+ return ShiftReport(
90
+ window_start=shift_start,
91
+ window_end=shift_end,
92
+ incidents=incidents,
93
+ evaluations_count=len(evaluations),
94
+ pending_reviews=len(pending),
95
+ )
96
+
97
+
98
+ def is_quiet(report: ShiftReport) -> bool:
99
+ """Determine if a shift was quiet — no incidents and no pending reviews."""
100
+ return len(report.incidents) == 0 and report.pending_reviews == 0
101
+
102
+
103
+ def render_shift_report(report: ShiftReport) -> str:
104
+ """Render a ShiftReport to human-readable text."""
105
+ start = report.window_start.strftime("%b %d, %H:%M")
106
+ end = report.window_end.strftime("%b %d, %H:%M")
107
+
108
+ lines = [f"Shift Report: {start} \u2192 {end}", ""]
109
+
110
+ if is_quiet(report):
111
+ lines.append("Nothing required your attention. \u2713")
112
+ else:
113
+ count = len(report.incidents)
114
+ if count > 0:
115
+ lines.append(f"{count} incident{'s' if count != 1 else ''} during this shift.")
116
+ for inc in report.incidents:
117
+ custom = inc.metadata.custom
118
+ sev = custom.get("severity", "?")
119
+ dur = custom.get("duration", "")
120
+ dur_str = f" ({dur})" if dur else ""
121
+ lines.append(f" P{sev}: {inc.subject.summary}{dur_str}")
122
+
123
+ if report.evaluations_count > 0:
124
+ lines.append(f"\nEvaluations: {report.evaluations_count}")
125
+
126
+ if report.pending_reviews > 0:
127
+ lines.append(f"Pending reviews: {report.pending_reviews}")
128
+
129
+ return "\n".join(lines)
@@ -0,0 +1,91 @@
1
+ """Alert suppression — 'don't page me for this'.
2
+
3
+ Suppression rules let SREs mute known non-issues (e.g., nightly backup
4
+ latency spikes) with a baseline + override threshold. If the current
5
+ value exceeds the override threshold, the suppression is overridden
6
+ and the SRE is paged with context explaining why.
7
+
8
+ No model call. Baseline is arithmetic (historical mean). Override
9
+ detection is a single comparison.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timedelta, timezone
16
+ from typing import Any
17
+
18
+ import structlog
19
+
20
+ logger = structlog.get_logger(__name__)
21
+
22
+ _DEFAULT_OVERRIDE_MULTIPLIER = 3.0
23
+ _DEFAULT_REVIEW_DAYS = 30
24
+
25
+
26
+ @dataclass
27
+ class Suppression:
28
+ """A suppression rule for a service metric."""
29
+
30
+ service: str
31
+ metric: str
32
+ window: dict[str, Any] # {type, start, end, timezone?}
33
+ reason: str
34
+ baseline: float
35
+ override_threshold: float
36
+ created_by: str = "unknown"
37
+ created_at: datetime | None = None
38
+ review_after: datetime | None = None
39
+
40
+
41
+ def create_suppression(
42
+ *,
43
+ service: str,
44
+ metric: str,
45
+ window: dict[str, Any],
46
+ reason: str,
47
+ baseline: float,
48
+ override_multiplier: float = _DEFAULT_OVERRIDE_MULTIPLIER,
49
+ created_by: str = "unknown",
50
+ ) -> Suppression:
51
+ """Create a suppression rule from a baseline measurement.
52
+
53
+ The override threshold is ``baseline * override_multiplier``.
54
+ If the metric exceeds this during the suppressed window,
55
+ the suppression is overridden and the SRE is paged.
56
+ """
57
+ if baseline <= 0:
58
+ msg = f"Baseline must be positive, got {baseline}"
59
+ raise ValueError(msg)
60
+
61
+ now = datetime.now(timezone.utc)
62
+ override_threshold = baseline * override_multiplier
63
+
64
+ logger.info(
65
+ "suppression_created",
66
+ service=service,
67
+ metric=metric,
68
+ baseline=baseline,
69
+ override_threshold=override_threshold,
70
+ reason=reason,
71
+ )
72
+
73
+ return Suppression(
74
+ service=service,
75
+ metric=metric,
76
+ window=window,
77
+ reason=reason,
78
+ baseline=baseline,
79
+ override_threshold=override_threshold,
80
+ created_by=created_by,
81
+ created_at=now,
82
+ review_after=now + timedelta(days=_DEFAULT_REVIEW_DAYS),
83
+ )
84
+
85
+
86
+ def check_suppression_override(suppression: Suppression, current_value: float) -> bool:
87
+ """Return True if the current value exceeds the override threshold.
88
+
89
+ At exactly the threshold, suppression holds (must strictly exceed).
90
+ """
91
+ return current_value > suppression.override_threshold