Skip to content

Commit c8fb2b1

Browse files
authored
Merge pull request #7 from itenev/feat/incident-history-queries
feat: implement incident history queries and context assembly
2 parents a2fb3f6 + c6a5b37 commit c8fb2b1

4 files changed

Lines changed: 290 additions & 5 deletions

File tree

docs/hermes-implementation-plan.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,12 +134,13 @@
134134
**Files:**
135135
- Modify: `src/agentic_node_ops/database.py`
136136
- Create: `src/agentic_node_ops/context.py`
137+
- Create: `tests/test_context.py`
137138

138-
**Steps:**
139-
1. Implement `get_recent_incidents()`, `get_corrections()`, runbook stats queries
140-
2. Implement `build_hermes_context()` from design doc §6
141-
3. Write tests
142-
4. Verify context assembly produces correct prompt text
139+
[x] Complete — Implemented context assembly and history queries:
140+
- `database.py`: Added `get_runbook_stats()` and `get_host_baselines()` methods.
141+
- `context.py`: Implemented `build_hermes_context()` combining current state, recent incidents, operator corrections, runbook performance, and host baselines into a single structured prompt.
142+
- `test_context.py`: 6 tests covering DB queries and context builder formatting.
143+
- All tests passing.
143144

144145
---
145146

src/agentic_node_ops/context.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Context assembly for Hermes prompts."""
2+
3+
import json
4+
5+
from .database import Database
6+
from .types import NotificationPayload
7+
8+
9+
def build_hermes_context(payload: NotificationPayload, db: Database) -> str:
10+
"""
11+
Assemble the context prompt for Hermes analysis.
12+
13+
Combines current state, recent incident history, operator corrections,
14+
runbook performance, and host baselines into a single structured prompt.
15+
"""
16+
# Recent incidents
17+
recent_incidents = db.get_recent_incidents(
18+
alert_type=payload.alert_type, host=payload.host, limit=5
19+
)
20+
if recent_incidents:
21+
incidents_text = "\n".join(
22+
f"- {inc['fired_at']}: {inc['outcome'] or 'unresolved'} "
23+
f"(severity: {inc['severity']}, analysis: {(inc.get('hermes_analysis') or 'N/A')[:100]}{'...' if len(inc.get('hermes_analysis') or '') > 100 else ''})"
24+
for inc in recent_incidents
25+
)
26+
else:
27+
incidents_text = (
28+
"- No prior incidents recorded for this alert type on this host."
29+
)
30+
31+
# Operator corrections
32+
corrections = db.get_corrections(alert_type=payload.alert_type, host=payload.host)
33+
if corrections:
34+
corrections_text = "\n".join(f"- {c}" for c in corrections)
35+
else:
36+
corrections_text = "- No operator corrections recorded."
37+
38+
# Runbook stats
39+
runbook_id = payload.runbook_id or "unknown"
40+
stats = db.get_runbook_stats(runbook_id)
41+
success_rate_pct = stats["success_rate"] * 100
42+
failed_cases_text = "\n".join(f"- {case}" for case in stats["failed_cases"])
43+
44+
# Host baselines
45+
baselines = db.get_host_baselines(host=payload.host)
46+
if baselines:
47+
baselines_text = "\n".join(
48+
f"- {metric}: p50={data['p50']}, p95={data['p95']}"
49+
for metric, data in baselines.items()
50+
)
51+
else:
52+
baselines_text = "- No baselines recorded for this host."
53+
54+
# Diagnostics snapshot
55+
diagnostics_text = (
56+
json.dumps(payload.diagnostics, indent=2)
57+
if payload.diagnostics
58+
else "No diagnostics available."
59+
)
60+
61+
prompt = f"""You are analyzing a {payload.alert_type} alert on {payload.host}.
62+
63+
CURRENT STATE:
64+
{diagnostics_text}
65+
66+
RECENT INCIDENT HISTORY (last 5 similar incidents on this host):
67+
{incidents_text}
68+
69+
OPERATOR CORRECTIONS FOR THIS ALERT TYPE ON THIS HOST:
70+
{corrections_text}
71+
72+
RUNBOOK PERFORMANCE:
73+
Runbook '{runbook_id}' has resolved this alert type {success_rate_pct:.0f}% of the time.
74+
Known failure cases:
75+
{failed_cases_text}
76+
77+
HOST BASELINES:
78+
{baselines_text}
79+
80+
Your job: explain what is likely happening, why, and what the operator should do.
81+
Be specific. If this matches a pattern from history, say so explicitly.
82+
"""
83+
return prompt

src/agentic_node_ops/database.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,58 @@ def get_last_processed(self, alert_type: str, host: str) -> Optional[dict]:
233233
)
234234
row = cursor.fetchone()
235235
return dict(row) if row else None
236+
237+
def get_runbook_stats(self, runbook_id: str) -> dict:
238+
"""Get success rate and known failure cases for a runbook."""
239+
with self._get_connection() as conn:
240+
cursor = conn.execute(
241+
"""
242+
SELECT
243+
COUNT(*) as total,
244+
SUM(CASE WHEN outcome = 'resolved' THEN 1 ELSE 0 END) as resolved
245+
FROM runbook_outcomes
246+
WHERE runbook_id = ?
247+
""",
248+
(runbook_id,),
249+
)
250+
row = cursor.fetchone()
251+
total = row["total"] or 0
252+
resolved = row["resolved"] or 0
253+
success_rate = resolved / total if total > 0 else 0.0
254+
255+
cursor = conn.execute(
256+
"""
257+
SELECT action_taken, outcome
258+
FROM runbook_outcomes
259+
WHERE runbook_id = ? AND outcome != 'resolved'
260+
LIMIT 3
261+
""",
262+
(runbook_id,),
263+
)
264+
failed_cases = [
265+
f"{row['action_taken']} ({row['outcome']})" for row in cursor.fetchall()
266+
]
267+
268+
return {
269+
"success_rate": success_rate,
270+
"failed_cases": failed_cases if failed_cases else ["None recorded"],
271+
}
272+
273+
def get_host_baselines(self, host: str) -> dict[str, dict]:
274+
"""Get all baseline p50/p95 metrics for a specific host."""
275+
with self._get_connection() as conn:
276+
cursor = conn.execute(
277+
"""
278+
SELECT metric, baseline_p50, baseline_p95
279+
FROM host_fingerprints
280+
WHERE host = ?
281+
""",
282+
(host,),
283+
)
284+
return {
285+
row["metric"]: {
286+
"p50": row["baseline_p50"],
287+
"p95": row["baseline_p95"],
288+
}
289+
for row in cursor.fetchall()
290+
}

tests/test_context.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Tests for context assembly and database history queries."""
2+
3+
import tempfile
4+
from pathlib import Path
5+
6+
import pytest
7+
8+
from agentic_node_ops.context import build_hermes_context
9+
from agentic_node_ops.database import Database
10+
from agentic_node_ops.types import NotificationPayload, Severity
11+
12+
13+
@pytest.fixture
14+
def temp_db():
15+
"""Provide a temporary SQLite database for testing."""
16+
with tempfile.TemporaryDirectory() as tmpdir:
17+
db_path = Path(tmpdir) / "test_incidents.db"
18+
db = Database(db_path=str(db_path))
19+
yield db
20+
21+
22+
def test_get_runbook_stats_empty(temp_db: Database):
23+
"""Test get_runbook_stats returns 0.0 success rate when no data exists."""
24+
stats = temp_db.get_runbook_stats("test_runbook")
25+
assert stats["success_rate"] == 0.0
26+
assert stats["failed_cases"] == ["None recorded"]
27+
28+
29+
def test_get_runbook_stats_with_data(temp_db: Database):
30+
"""Test get_runbook_stats calculates success rate correctly."""
31+
with temp_db._get_connection() as conn:
32+
conn.execute(
33+
"""
34+
INSERT INTO runbook_outcomes (id, runbook_id, host, action_taken, outcome, time_to_resolve)
35+
VALUES ('1', 'rb1', 'host1', 'restart', 'resolved', 60),
36+
('2', 'rb1', 'host1', 'restart', 'did_not_help', 30),
37+
('3', 'rb1', 'host2', 'wipe', 'resolved', 120)
38+
"""
39+
)
40+
conn.commit()
41+
42+
stats = temp_db.get_runbook_stats("rb1")
43+
assert stats["success_rate"] == pytest.approx(2 / 3)
44+
assert len(stats["failed_cases"]) == 1
45+
assert "restart (did_not_help)" in stats["failed_cases"][0]
46+
47+
48+
def test_get_host_baselines_empty(temp_db: Database):
49+
"""Test get_host_baselines returns empty dict when no data exists."""
50+
baselines = temp_db.get_host_baselines("host1")
51+
assert baselines == {}
52+
53+
54+
def test_get_host_baselines_with_data(temp_db: Database):
55+
"""Test get_host_baselines returns correct metrics."""
56+
with temp_db._get_connection() as conn:
57+
conn.execute(
58+
"""
59+
INSERT INTO host_fingerprints (host, metric, baseline_p50, baseline_p95, last_updated)
60+
VALUES ('host1', 'peer_count', 45.0, 60.0, '2024-01-01'),
61+
('host1', 'validator_count', 10.0, 12.0, '2024-01-01')
62+
"""
63+
)
64+
conn.commit()
65+
66+
baselines = temp_db.get_host_baselines("host1")
67+
assert len(baselines) == 2
68+
assert baselines["peer_count"]["p50"] == 45.0
69+
assert baselines["peer_count"]["p95"] == 60.0
70+
assert baselines["validator_count"]["p50"] == 10.0
71+
72+
73+
def test_build_hermes_context_no_history(temp_db: Database):
74+
"""Test build_hermes_context formats correctly with no history."""
75+
payload = NotificationPayload(
76+
incident_id="inc-1",
77+
alert_type="consensus_desync",
78+
severity=Severity.HIGH,
79+
host="node-1",
80+
title="Consensus Desync on node-1",
81+
summary="Node is out of sync",
82+
diagnostics={"peer_count": "2", "syncing": "true"},
83+
runbook_id="consensus_desync",
84+
)
85+
86+
context = build_hermes_context(payload, temp_db)
87+
88+
assert "consensus_desync" in context
89+
assert "node-1" in context
90+
assert "No prior incidents recorded" in context
91+
assert "No operator corrections recorded" in context
92+
assert "0% of the time" in context
93+
assert "No baselines recorded" in context
94+
assert '"peer_count": "2"' in context
95+
96+
97+
def test_build_hermes_context_with_history(temp_db: Database):
98+
"""Test build_hermes_context includes history and baselines when present."""
99+
# Insert incident
100+
with temp_db._get_connection() as conn:
101+
conn.execute(
102+
"""
103+
INSERT INTO incidents (id, alert_type, host, severity, fired_at, outcome, hermes_analysis)
104+
VALUES ('inc-0', 'consensus_desync', 'node-1', 'high', '2024-01-01 10:00:00', 'resolved', 'Restarted peer')
105+
"""
106+
)
107+
# Insert correction
108+
conn.execute(
109+
"""
110+
INSERT INTO operator_corrections (id, incident_id, alert_type, host, correction)
111+
VALUES ('corr-1', 'inc-0', 'consensus_desync', 'node-1', 'Always check peer count first')
112+
"""
113+
)
114+
# Insert runbook outcome
115+
conn.execute(
116+
"""
117+
INSERT INTO runbook_outcomes (id, runbook_id, host, action_taken, outcome, time_to_resolve)
118+
VALUES ('out-1', 'consensus_desync', 'node-1', 'restart_consensus_client', 'resolved', 60)
119+
"""
120+
)
121+
# Insert baseline
122+
conn.execute(
123+
"""
124+
INSERT INTO host_fingerprints (host, metric, baseline_p50, baseline_p95, last_updated)
125+
VALUES ('node-1', 'peer_count', 50.0, 80.0, '2024-01-01')
126+
"""
127+
)
128+
conn.commit()
129+
130+
payload = NotificationPayload(
131+
incident_id="inc-1",
132+
alert_type="consensus_desync",
133+
severity=Severity.HIGH,
134+
host="node-1",
135+
title="Consensus Desync on node-1",
136+
summary="Node is out of sync",
137+
diagnostics={"peer_count": "2"},
138+
runbook_id="consensus_desync",
139+
)
140+
141+
context = build_hermes_context(payload, temp_db)
142+
143+
assert "resolved (severity: high, analysis: Restarted peer)" in context
144+
assert "Always check peer count first" in context
145+
assert "100% of the time" in context
146+
assert "peer_count: p50=50.0, p95=80.0" in context

0 commit comments

Comments
 (0)