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.
- nthlayer_workers/__init__.py +5 -0
- nthlayer_workers/cli.py +234 -0
- nthlayer_workers/correlate/__init__.py +1 -0
- nthlayer_workers/correlate/cli.py +847 -0
- nthlayer_workers/correlate/config.py +111 -0
- nthlayer_workers/correlate/correlation/__init__.py +1 -0
- nthlayer_workers/correlate/correlation/changes.py +87 -0
- nthlayer_workers/correlate/correlation/dedup.py +62 -0
- nthlayer_workers/correlate/correlation/engine.py +244 -0
- nthlayer_workers/correlate/correlation/temporal.py +79 -0
- nthlayer_workers/correlate/correlation/topology.py +104 -0
- nthlayer_workers/correlate/ingestion/__init__.py +1 -0
- nthlayer_workers/correlate/ingestion/protocol.py +10 -0
- nthlayer_workers/correlate/ingestion/severity.py +18 -0
- nthlayer_workers/correlate/ingestion/webhook.py +197 -0
- nthlayer_workers/correlate/notifications.py +85 -0
- nthlayer_workers/correlate/prometheus.py +234 -0
- nthlayer_workers/correlate/reasoning.py +375 -0
- nthlayer_workers/correlate/session.py +189 -0
- nthlayer_workers/correlate/snapshot/__init__.py +1 -0
- nthlayer_workers/correlate/snapshot/generator.py +170 -0
- nthlayer_workers/correlate/snapshot/model.py +177 -0
- nthlayer_workers/correlate/snapshot/token.py +14 -0
- nthlayer_workers/correlate/state.py +88 -0
- nthlayer_workers/correlate/store/__init__.py +5 -0
- nthlayer_workers/correlate/store/protocol.py +48 -0
- nthlayer_workers/correlate/store/sqlite.py +443 -0
- nthlayer_workers/correlate/summary.py +180 -0
- nthlayer_workers/correlate/traces/__init__.py +1 -0
- nthlayer_workers/correlate/traces/protocol.py +120 -0
- nthlayer_workers/correlate/traces/tempo.py +667 -0
- nthlayer_workers/correlate/traces/topology.py +39 -0
- nthlayer_workers/correlate/types.py +77 -0
- nthlayer_workers/correlate/worker.py +630 -0
- nthlayer_workers/learn/__init__.py +5 -0
- nthlayer_workers/learn/__main__.py +5 -0
- nthlayer_workers/learn/cli.py +164 -0
- nthlayer_workers/learn/retrospective.py +381 -0
- nthlayer_workers/learn/trends.py +102 -0
- nthlayer_workers/learn/worker.py +366 -0
- nthlayer_workers/measure/__init__.py +3 -0
- nthlayer_workers/measure/__main__.py +5 -0
- nthlayer_workers/measure/_parsing.py +15 -0
- nthlayer_workers/measure/adapters/__init__.py +0 -0
- nthlayer_workers/measure/adapters/_util.py +24 -0
- nthlayer_workers/measure/adapters/devin.py +119 -0
- nthlayer_workers/measure/adapters/gastown.py +88 -0
- nthlayer_workers/measure/adapters/prometheus.py +277 -0
- nthlayer_workers/measure/adapters/protocol.py +20 -0
- nthlayer_workers/measure/adapters/webhook.py +161 -0
- nthlayer_workers/measure/api/__init__.py +0 -0
- nthlayer_workers/measure/api/normalise.py +50 -0
- nthlayer_workers/measure/api/queue.py +243 -0
- nthlayer_workers/measure/api/response.py +51 -0
- nthlayer_workers/measure/api/server.py +504 -0
- nthlayer_workers/measure/calibration/__init__.py +0 -0
- nthlayer_workers/measure/calibration/loop.py +62 -0
- nthlayer_workers/measure/calibration/slos.py +212 -0
- nthlayer_workers/measure/calibration/verdict_calibration.py +31 -0
- nthlayer_workers/measure/cli.py +753 -0
- nthlayer_workers/measure/config.py +191 -0
- nthlayer_workers/measure/detection/__init__.py +6 -0
- nthlayer_workers/measure/detection/detector.py +82 -0
- nthlayer_workers/measure/detection/protocol.py +29 -0
- nthlayer_workers/measure/governance/__init__.py +0 -0
- nthlayer_workers/measure/governance/engine.py +163 -0
- nthlayer_workers/measure/manifest.py +77 -0
- nthlayer_workers/measure/notifications.py +53 -0
- nthlayer_workers/measure/pipeline/__init__.py +0 -0
- nthlayer_workers/measure/pipeline/evaluator.py +155 -0
- nthlayer_workers/measure/pipeline/router.py +160 -0
- nthlayer_workers/measure/store/__init__.py +0 -0
- nthlayer_workers/measure/store/protocol.py +38 -0
- nthlayer_workers/measure/store/sqlite.py +276 -0
- nthlayer_workers/measure/telemetry.py +116 -0
- nthlayer_workers/measure/tiering/__init__.py +0 -0
- nthlayer_workers/measure/tiering/classifier.py +58 -0
- nthlayer_workers/measure/tiering/promotion.py +118 -0
- nthlayer_workers/measure/trends/__init__.py +0 -0
- nthlayer_workers/measure/trends/tracker.py +72 -0
- nthlayer_workers/measure/types.py +75 -0
- nthlayer_workers/measure/worker.py +439 -0
- nthlayer_workers/observe/__init__.py +25 -0
- nthlayer_workers/observe/__main__.py +5 -0
- nthlayer_workers/observe/api/__init__.py +1 -0
- nthlayer_workers/observe/assessment.py +95 -0
- nthlayer_workers/observe/cli.py +737 -0
- nthlayer_workers/observe/config.py +11 -0
- nthlayer_workers/observe/db/__init__.py +1 -0
- nthlayer_workers/observe/decision_records.py +220 -0
- nthlayer_workers/observe/dependencies/__init__.py +18 -0
- nthlayer_workers/observe/dependencies/discovery.py +294 -0
- nthlayer_workers/observe/dependencies/providers/__init__.py +48 -0
- nthlayer_workers/observe/dependencies/providers/backstage.py +467 -0
- nthlayer_workers/observe/dependencies/providers/base.py +76 -0
- nthlayer_workers/observe/dependencies/providers/consul.py +518 -0
- nthlayer_workers/observe/dependencies/providers/etcd.py +360 -0
- nthlayer_workers/observe/dependencies/providers/kubernetes.py +682 -0
- nthlayer_workers/observe/dependencies/providers/prometheus.py +368 -0
- nthlayer_workers/observe/dependencies/providers/zookeeper.py +399 -0
- nthlayer_workers/observe/deployments/__init__.py +1 -0
- nthlayer_workers/observe/discovery/__init__.py +14 -0
- nthlayer_workers/observe/discovery/classifier.py +66 -0
- nthlayer_workers/observe/discovery/client.py +189 -0
- nthlayer_workers/observe/discovery/models.py +53 -0
- nthlayer_workers/observe/drift/__init__.py +26 -0
- nthlayer_workers/observe/drift/analyzer.py +383 -0
- nthlayer_workers/observe/drift/models.py +174 -0
- nthlayer_workers/observe/drift/patterns.py +88 -0
- nthlayer_workers/observe/explanation.py +118 -0
- nthlayer_workers/observe/gate/__init__.py +39 -0
- nthlayer_workers/observe/gate/conditions.py +92 -0
- nthlayer_workers/observe/gate/correlator.py +154 -0
- nthlayer_workers/observe/gate/evaluator.py +192 -0
- nthlayer_workers/observe/gate/policies.py +226 -0
- nthlayer_workers/observe/gate_adapter.py +40 -0
- nthlayer_workers/observe/incident.py +36 -0
- nthlayer_workers/observe/portfolio/__init__.py +17 -0
- nthlayer_workers/observe/portfolio/aggregator.py +168 -0
- nthlayer_workers/observe/portfolio/scorer.py +13 -0
- nthlayer_workers/observe/slo/__init__.py +19 -0
- nthlayer_workers/observe/slo/collector.py +235 -0
- nthlayer_workers/observe/slo/spec_loader.py +40 -0
- nthlayer_workers/observe/sqlite_store.py +152 -0
- nthlayer_workers/observe/store.py +92 -0
- nthlayer_workers/observe/verification/__init__.py +22 -0
- nthlayer_workers/observe/verification/exporter_guidance.py +146 -0
- nthlayer_workers/observe/verification/extractor.py +127 -0
- nthlayer_workers/observe/verification/models.py +101 -0
- nthlayer_workers/observe/verification/verifier.py +111 -0
- nthlayer_workers/observe/worker.py +332 -0
- nthlayer_workers/respond/__init__.py +2 -0
- nthlayer_workers/respond/__main__.py +4 -0
- nthlayer_workers/respond/agents/__init__.py +0 -0
- nthlayer_workers/respond/agents/base.py +556 -0
- nthlayer_workers/respond/agents/communication.py +115 -0
- nthlayer_workers/respond/agents/investigation.py +124 -0
- nthlayer_workers/respond/agents/remediation.py +219 -0
- nthlayer_workers/respond/agents/triage.py +132 -0
- nthlayer_workers/respond/cli.py +772 -0
- nthlayer_workers/respond/config.py +135 -0
- nthlayer_workers/respond/context_store.py +256 -0
- nthlayer_workers/respond/coordinator.py +487 -0
- nthlayer_workers/respond/metrics.py +104 -0
- nthlayer_workers/respond/notification_backends/__init__.py +1 -0
- nthlayer_workers/respond/notification_backends/ntfy_backend.py +158 -0
- nthlayer_workers/respond/notification_backends/protocol.py +59 -0
- nthlayer_workers/respond/notification_backends/slack_backend.py +203 -0
- nthlayer_workers/respond/notification_backends/stdout_backend.py +56 -0
- nthlayer_workers/respond/notifications.py +247 -0
- nthlayer_workers/respond/oncall/__init__.py +1 -0
- nthlayer_workers/respond/oncall/escalation.py +103 -0
- nthlayer_workers/respond/oncall/runner.py +193 -0
- nthlayer_workers/respond/oncall/schedule.py +243 -0
- nthlayer_workers/respond/safe_actions/__init__.py +0 -0
- nthlayer_workers/respond/safe_actions/actions.py +139 -0
- nthlayer_workers/respond/safe_actions/registry.py +171 -0
- nthlayer_workers/respond/safe_actions/webhook.py +194 -0
- nthlayer_workers/respond/server.py +357 -0
- nthlayer_workers/respond/sre/__init__.py +1 -0
- nthlayer_workers/respond/sre/brief.py +175 -0
- nthlayer_workers/respond/sre/delegation.py +101 -0
- nthlayer_workers/respond/sre/post_incident.py +146 -0
- nthlayer_workers/respond/sre/shift_report.py +129 -0
- nthlayer_workers/respond/sre/suppression.py +91 -0
- nthlayer_workers/respond/types.py +109 -0
- nthlayer_workers/respond/verdict_submission.py +56 -0
- nthlayer_workers/respond/worker.py +533 -0
- nthlayer_workers/respond/worker_helpers.py +140 -0
- nthlayer_workers/runner.py +198 -0
- nthlayer_workers-1.0.0.dist-info/METADATA +19 -0
- nthlayer_workers-1.0.0.dist-info/RECORD +175 -0
- nthlayer_workers-1.0.0.dist-info/WHEEL +5 -0
- nthlayer_workers-1.0.0.dist-info/entry_points.txt +2 -0
- nthlayer_workers-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Snapshot generator with token budget, priority tiers, and caching."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from nthlayer_workers.correlate.snapshot.token import CharDivFourEstimator, TokenEstimator
|
|
10
|
+
from nthlayer_workers.correlate.types import AgentState, CorrelationGroup
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SnapshotBudget:
|
|
15
|
+
max_tokens: int = 4000
|
|
16
|
+
reserved_instructions: int = 500
|
|
17
|
+
reserved_history: int = 500
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def available_for_groups(self) -> int:
|
|
21
|
+
return self.max_tokens - self.reserved_instructions - self.reserved_history
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SnapshotGenerator:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
budget: SnapshotBudget | None = None,
|
|
28
|
+
token_estimator: TokenEstimator | None = None,
|
|
29
|
+
cache_ttl_seconds: int = 900, # 15 min default
|
|
30
|
+
):
|
|
31
|
+
self._budget = budget or SnapshotBudget()
|
|
32
|
+
self._estimator = token_estimator or CharDivFourEstimator()
|
|
33
|
+
self._cache_ttl = cache_ttl_seconds
|
|
34
|
+
self._cached_hash: str | None = None
|
|
35
|
+
self._cached_prompt: str | None = None
|
|
36
|
+
self._cache_time: float = 0.0
|
|
37
|
+
|
|
38
|
+
def generate(
|
|
39
|
+
self,
|
|
40
|
+
groups: list[CorrelationGroup],
|
|
41
|
+
state: AgentState = AgentState.WATCHING,
|
|
42
|
+
service_context: dict | None = None,
|
|
43
|
+
force_refresh: bool = False,
|
|
44
|
+
) -> tuple[str, bool]:
|
|
45
|
+
"""Generate model prompt from correlation groups.
|
|
46
|
+
|
|
47
|
+
Returns (model_prompt, cache_hit).
|
|
48
|
+
If cache_hit is True, model_prompt is the cached prompt.
|
|
49
|
+
"""
|
|
50
|
+
# Compute content hash
|
|
51
|
+
content_hash = self._compute_hash(groups)
|
|
52
|
+
|
|
53
|
+
# Check cache (skip in INCIDENT mode)
|
|
54
|
+
if not force_refresh and state != AgentState.INCIDENT:
|
|
55
|
+
if self._is_cache_valid(content_hash, state):
|
|
56
|
+
return self._cached_prompt or "", True
|
|
57
|
+
|
|
58
|
+
# Sort groups by priority (P0 first)
|
|
59
|
+
sorted_groups = sorted(groups, key=lambda g: g.priority)
|
|
60
|
+
|
|
61
|
+
# Apply token budget
|
|
62
|
+
included_groups = self._apply_budget(sorted_groups)
|
|
63
|
+
|
|
64
|
+
# Assemble prompt
|
|
65
|
+
prompt = self._assemble_prompt(included_groups, service_context)
|
|
66
|
+
|
|
67
|
+
# Update cache
|
|
68
|
+
self._cached_hash = content_hash
|
|
69
|
+
self._cached_prompt = prompt
|
|
70
|
+
self._cache_time = time.monotonic()
|
|
71
|
+
|
|
72
|
+
return prompt, False
|
|
73
|
+
|
|
74
|
+
def _compute_hash(self, groups: list[CorrelationGroup]) -> str:
|
|
75
|
+
"""SHA256 of stable group content (services, event counts, priority)."""
|
|
76
|
+
parts = sorted(
|
|
77
|
+
f"{','.join(sorted(g.services))}:{g.event_count}:{g.priority}"
|
|
78
|
+
for g in groups
|
|
79
|
+
)
|
|
80
|
+
return hashlib.sha256("|".join(parts).encode()).hexdigest()
|
|
81
|
+
|
|
82
|
+
def _is_cache_valid(self, content_hash: str, state: AgentState) -> bool:
|
|
83
|
+
"""Check if cached snapshot is still valid."""
|
|
84
|
+
if self._cached_hash is None:
|
|
85
|
+
return False
|
|
86
|
+
if content_hash != self._cached_hash:
|
|
87
|
+
return False
|
|
88
|
+
# Check TTL
|
|
89
|
+
elapsed = time.monotonic() - self._cache_time
|
|
90
|
+
ttl = {
|
|
91
|
+
AgentState.WATCHING: 900,
|
|
92
|
+
AgentState.ALERT: 300,
|
|
93
|
+
AgentState.INCIDENT: 0, # never cache
|
|
94
|
+
AgentState.DEGRADED: 600,
|
|
95
|
+
}.get(state, self._cache_ttl)
|
|
96
|
+
return elapsed < ttl
|
|
97
|
+
|
|
98
|
+
def _apply_budget(self, sorted_groups: list[CorrelationGroup]) -> list[CorrelationGroup]:
|
|
99
|
+
"""Include groups until token budget exhausted. P0 always included."""
|
|
100
|
+
included = []
|
|
101
|
+
tokens_used = 0
|
|
102
|
+
budget = self._budget.available_for_groups
|
|
103
|
+
|
|
104
|
+
for group in sorted_groups:
|
|
105
|
+
group_text = self._serialize_group(group)
|
|
106
|
+
group_tokens = self._estimator.estimate(group_text)
|
|
107
|
+
|
|
108
|
+
if group.priority == 0:
|
|
109
|
+
# P0 always included, even over budget
|
|
110
|
+
included.append(group)
|
|
111
|
+
tokens_used += group_tokens
|
|
112
|
+
elif tokens_used + group_tokens <= budget:
|
|
113
|
+
included.append(group)
|
|
114
|
+
tokens_used += group_tokens
|
|
115
|
+
# else: drop (never truncate)
|
|
116
|
+
|
|
117
|
+
return included
|
|
118
|
+
|
|
119
|
+
def _serialize_group(self, group: CorrelationGroup) -> str:
|
|
120
|
+
"""Serialize a group to text for token counting and prompt inclusion."""
|
|
121
|
+
lines = [
|
|
122
|
+
f"## Correlation Group: {group.summary}",
|
|
123
|
+
f"Priority: P{group.priority}",
|
|
124
|
+
f"Services: {', '.join(group.services)}",
|
|
125
|
+
f"Events: {group.event_count}",
|
|
126
|
+
f"First seen: {group.first_seen}",
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
if group.change_candidates:
|
|
130
|
+
lines.append("Change candidates:")
|
|
131
|
+
for cc in group.change_candidates:
|
|
132
|
+
proximity_min = cc.temporal_proximity_seconds / 60
|
|
133
|
+
relation = "same service" if cc.same_service else "dependency"
|
|
134
|
+
lines.append(
|
|
135
|
+
f" - {cc.change.payload.get('change_type', 'unknown')} on "
|
|
136
|
+
f"{cc.affected_service} ({proximity_min:.0f}m ago, {relation})"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if group.topology:
|
|
140
|
+
lines.append(f"Topology: {' -> '.join(group.topology.topology_path)}")
|
|
141
|
+
|
|
142
|
+
return "\n".join(lines)
|
|
143
|
+
|
|
144
|
+
def _assemble_prompt(
|
|
145
|
+
self,
|
|
146
|
+
groups: list[CorrelationGroup],
|
|
147
|
+
service_context: dict | None,
|
|
148
|
+
) -> str:
|
|
149
|
+
"""Assemble the full model prompt."""
|
|
150
|
+
parts = []
|
|
151
|
+
|
|
152
|
+
if not groups:
|
|
153
|
+
parts.append("No elevated correlation groups detected. System appears healthy.")
|
|
154
|
+
return "\n\n".join(parts)
|
|
155
|
+
|
|
156
|
+
parts.append(f"Analyzing {len(groups)} correlation group(s):\n")
|
|
157
|
+
|
|
158
|
+
for group in groups:
|
|
159
|
+
parts.append(self._serialize_group(group))
|
|
160
|
+
|
|
161
|
+
if service_context:
|
|
162
|
+
parts.append("\nService context:")
|
|
163
|
+
parts.append(json.dumps(service_context, indent=2))
|
|
164
|
+
|
|
165
|
+
return "\n\n".join(parts)
|
|
166
|
+
|
|
167
|
+
def invalidate_cache(self) -> None:
|
|
168
|
+
"""Manually invalidate the cache (e.g., on state transition)."""
|
|
169
|
+
self._cached_hash = None
|
|
170
|
+
self._cached_prompt = None
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Model interface — the ZFC judgment boundary.
|
|
2
|
+
|
|
3
|
+
Used by: the `serve` and `replay` CLI subcommands (continuous snapshot generation).
|
|
4
|
+
See also: reasoning.py which serves the `correlate` subcommand (live triggered).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import structlog
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from nthlayer_common.prompts import load_prompt
|
|
13
|
+
from nthlayer_workers.correlate.types import CorrelationGroup
|
|
14
|
+
|
|
15
|
+
_PROMPT_PATH = Path(__file__).parent.parent.parent.parent / "prompts" / "snapshot.yaml"
|
|
16
|
+
|
|
17
|
+
logger = structlog.get_logger()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ModelInterface:
|
|
21
|
+
def __init__(self, model: str | None = None, max_tokens: int = 4096):
|
|
22
|
+
self._model = model # None → llm_call uses NTHLAYER_MODEL env var
|
|
23
|
+
self._max_tokens = max_tokens
|
|
24
|
+
|
|
25
|
+
async def interpret(
|
|
26
|
+
self,
|
|
27
|
+
prompt: str,
|
|
28
|
+
groups: list[CorrelationGroup],
|
|
29
|
+
verdict_store=None,
|
|
30
|
+
) -> list:
|
|
31
|
+
"""Call model, parse response into verdicts.
|
|
32
|
+
|
|
33
|
+
Returns list of Verdict objects (children + parent).
|
|
34
|
+
If model call fails, returns template-based verdicts with confidence 0.0.
|
|
35
|
+
"""
|
|
36
|
+
from nthlayer_common.verdicts import create as verdict_create
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
response = await self._call_model(prompt)
|
|
40
|
+
assessments = self._parse_response(response, groups)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
logger.warning("model_call_failed", error=str(e))
|
|
43
|
+
return self._create_template_verdicts(groups)
|
|
44
|
+
|
|
45
|
+
# Create per-correlation verdicts
|
|
46
|
+
child_verdicts = []
|
|
47
|
+
for assessment in assessments:
|
|
48
|
+
v = verdict_create(
|
|
49
|
+
subject={
|
|
50
|
+
"type": "correlation",
|
|
51
|
+
"service": assessment.get("service"),
|
|
52
|
+
"ref": assessment.get("group_id", ""),
|
|
53
|
+
"summary": assessment.get("summary", ""),
|
|
54
|
+
},
|
|
55
|
+
judgment={
|
|
56
|
+
"action": assessment.get("action", "flag"),
|
|
57
|
+
"confidence": assessment.get("confidence", 0.5),
|
|
58
|
+
"reasoning": assessment.get("reasoning", ""),
|
|
59
|
+
"tags": assessment.get("tags", []),
|
|
60
|
+
},
|
|
61
|
+
producer={"system": "nthlayer-correlate", "model": self._model},
|
|
62
|
+
)
|
|
63
|
+
child_verdicts.append(v)
|
|
64
|
+
if verdict_store:
|
|
65
|
+
verdict_store.put(v)
|
|
66
|
+
|
|
67
|
+
# Create parent snapshot verdict
|
|
68
|
+
overall_action = "escalate" if any(
|
|
69
|
+
a.get("action") == "escalate" for a in assessments
|
|
70
|
+
) else "flag"
|
|
71
|
+
overall_confidence = (
|
|
72
|
+
sum(a.get("confidence", 0.5) for a in assessments) / len(assessments)
|
|
73
|
+
if assessments else 0.0
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Collect state transition tags
|
|
77
|
+
tags = []
|
|
78
|
+
for a in assessments:
|
|
79
|
+
tags.extend(a.get("tags", []))
|
|
80
|
+
|
|
81
|
+
parent = verdict_create(
|
|
82
|
+
subject={
|
|
83
|
+
"type": "correlation",
|
|
84
|
+
"service": None,
|
|
85
|
+
"ref": "snapshot",
|
|
86
|
+
"summary": f"Snapshot: {len(groups)} group(s), {len(assessments)} assessed",
|
|
87
|
+
},
|
|
88
|
+
judgment={
|
|
89
|
+
"action": overall_action,
|
|
90
|
+
"confidence": overall_confidence,
|
|
91
|
+
"reasoning": f"Assessed {len(assessments)} correlation group(s)",
|
|
92
|
+
"tags": list(set(tags)),
|
|
93
|
+
},
|
|
94
|
+
producer={"system": "nthlayer-correlate", "model": self._model},
|
|
95
|
+
)
|
|
96
|
+
parent.lineage.children = [v.id for v in child_verdicts]
|
|
97
|
+
if verdict_store:
|
|
98
|
+
verdict_store.put(parent)
|
|
99
|
+
|
|
100
|
+
return child_verdicts + [parent]
|
|
101
|
+
|
|
102
|
+
async def _call_model(self, prompt: str) -> str:
|
|
103
|
+
"""Call the LLM via the shared nthlayer-common wrapper."""
|
|
104
|
+
import asyncio
|
|
105
|
+
from nthlayer_common.llm import llm_call
|
|
106
|
+
|
|
107
|
+
result = await asyncio.to_thread(
|
|
108
|
+
llm_call,
|
|
109
|
+
system=self._build_system_prompt(),
|
|
110
|
+
user=prompt,
|
|
111
|
+
model=self._model,
|
|
112
|
+
max_tokens=self._max_tokens,
|
|
113
|
+
)
|
|
114
|
+
return result.text
|
|
115
|
+
|
|
116
|
+
def _build_system_prompt(self) -> str:
|
|
117
|
+
spec = load_prompt(_PROMPT_PATH)
|
|
118
|
+
return spec.system
|
|
119
|
+
|
|
120
|
+
def _parse_response(self, response_text: str, groups: list[CorrelationGroup]) -> list[dict]:
|
|
121
|
+
"""Parse model JSON response."""
|
|
122
|
+
from nthlayer_common.parsing import clamp, strip_markdown_fences
|
|
123
|
+
|
|
124
|
+
text = strip_markdown_fences(response_text)
|
|
125
|
+
data = json.loads(text)
|
|
126
|
+
assessments = data.get("assessments", [])
|
|
127
|
+
|
|
128
|
+
# Validate actions and clamp confidence before verdict creation
|
|
129
|
+
valid_actions = {"flag", "escalate", "defer"}
|
|
130
|
+
for a in assessments:
|
|
131
|
+
if a.get("action") not in valid_actions:
|
|
132
|
+
a["action"] = "flag"
|
|
133
|
+
a["confidence"] = clamp(float(a.get("confidence", 0.5)))
|
|
134
|
+
|
|
135
|
+
return assessments
|
|
136
|
+
|
|
137
|
+
def _create_template_verdicts(self, groups: list[CorrelationGroup]) -> list:
|
|
138
|
+
"""Degraded mode: template-based verdicts with confidence 0.0."""
|
|
139
|
+
from nthlayer_common.verdicts import create as verdict_create
|
|
140
|
+
|
|
141
|
+
child_verdicts = []
|
|
142
|
+
for group in groups:
|
|
143
|
+
v = verdict_create(
|
|
144
|
+
subject={
|
|
145
|
+
"type": "correlation",
|
|
146
|
+
"service": group.services[0] if group.services else None,
|
|
147
|
+
"ref": group.id,
|
|
148
|
+
"summary": group.summary,
|
|
149
|
+
},
|
|
150
|
+
judgment={
|
|
151
|
+
"action": "flag",
|
|
152
|
+
"confidence": 0.0,
|
|
153
|
+
"reasoning": "template-based, model unavailable",
|
|
154
|
+
"tags": ["degraded"],
|
|
155
|
+
},
|
|
156
|
+
producer={"system": "nthlayer-correlate"},
|
|
157
|
+
)
|
|
158
|
+
child_verdicts.append(v)
|
|
159
|
+
|
|
160
|
+
parent = verdict_create(
|
|
161
|
+
subject={
|
|
162
|
+
"type": "correlation",
|
|
163
|
+
"service": None,
|
|
164
|
+
"ref": "snapshot-degraded",
|
|
165
|
+
"summary": f"Degraded snapshot: {len(groups)} group(s), no model assessment",
|
|
166
|
+
},
|
|
167
|
+
judgment={
|
|
168
|
+
"action": "flag",
|
|
169
|
+
"confidence": 0.0,
|
|
170
|
+
"reasoning": "template-based, model unavailable",
|
|
171
|
+
"tags": ["degraded"],
|
|
172
|
+
},
|
|
173
|
+
producer={"system": "nthlayer-correlate"},
|
|
174
|
+
)
|
|
175
|
+
parent.lineage.children = [v.id for v in child_verdicts]
|
|
176
|
+
|
|
177
|
+
return child_verdicts + [parent]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Token estimation. Protocol + simple char/4 estimator for Tier 1."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TokenEstimator(Protocol):
|
|
8
|
+
def estimate(self, text: str) -> int: ...
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CharDivFourEstimator:
|
|
12
|
+
"""len(text) // 4 — good enough for Tier 1."""
|
|
13
|
+
def estimate(self, text: str) -> int:
|
|
14
|
+
return len(text) // 4
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Agent state machine for SitRep."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
from nthlayer_workers.correlate.types import AgentState, CorrelationGroup
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StateMachine:
|
|
10
|
+
"""Deterministic state machine. Transitions are transport, not judgment."""
|
|
11
|
+
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self.state = AgentState.WATCHING
|
|
14
|
+
self._alert_start: datetime | None = None
|
|
15
|
+
self._calm_since: datetime | None = None
|
|
16
|
+
|
|
17
|
+
def update(
|
|
18
|
+
self,
|
|
19
|
+
groups: list[CorrelationGroup],
|
|
20
|
+
model_healthy: bool = True,
|
|
21
|
+
) -> AgentState:
|
|
22
|
+
"""Evaluate state transition based on correlation output."""
|
|
23
|
+
now = datetime.now(timezone.utc)
|
|
24
|
+
|
|
25
|
+
# Model failure → DEGRADED (from any state except INCIDENT)
|
|
26
|
+
if not model_healthy and self.state != AgentState.INCIDENT:
|
|
27
|
+
self.state = AgentState.DEGRADED
|
|
28
|
+
self._calm_since = None
|
|
29
|
+
return self.state
|
|
30
|
+
|
|
31
|
+
# DEGRADED + model recovery → WATCHING
|
|
32
|
+
if self.state == AgentState.DEGRADED and model_healthy:
|
|
33
|
+
self.state = AgentState.WATCHING
|
|
34
|
+
self._calm_since = now
|
|
35
|
+
return self.state
|
|
36
|
+
|
|
37
|
+
has_p0 = any(g.priority == 0 for g in groups)
|
|
38
|
+
p1_count = sum(1 for g in groups if g.priority == 1)
|
|
39
|
+
has_elevated = has_p0 or p1_count >= 2
|
|
40
|
+
|
|
41
|
+
if self.state == AgentState.WATCHING:
|
|
42
|
+
if has_elevated:
|
|
43
|
+
self.state = AgentState.ALERT
|
|
44
|
+
self._alert_start = now
|
|
45
|
+
self._calm_since = None
|
|
46
|
+
elif self.state == AgentState.ALERT:
|
|
47
|
+
if has_elevated:
|
|
48
|
+
self._calm_since = None
|
|
49
|
+
else:
|
|
50
|
+
if self._calm_since is None:
|
|
51
|
+
self._calm_since = now
|
|
52
|
+
elif (now - self._calm_since).total_seconds() >= 600: # 10 min
|
|
53
|
+
self.state = AgentState.WATCHING
|
|
54
|
+
self._alert_start = None
|
|
55
|
+
self._calm_since = None
|
|
56
|
+
|
|
57
|
+
return self.state
|
|
58
|
+
|
|
59
|
+
def declare_incident(self) -> None:
|
|
60
|
+
"""External incident declaration → INCIDENT."""
|
|
61
|
+
self.state = AgentState.INCIDENT
|
|
62
|
+
self._calm_since = None
|
|
63
|
+
|
|
64
|
+
def resolve_incident(self) -> None:
|
|
65
|
+
"""Incident resolved → WATCHING."""
|
|
66
|
+
self.state = AgentState.WATCHING
|
|
67
|
+
self._alert_start = None
|
|
68
|
+
self._calm_since = datetime.now(timezone.utc)
|
|
69
|
+
|
|
70
|
+
def get_interval(self) -> int:
|
|
71
|
+
"""Snapshot interval in seconds for current state."""
|
|
72
|
+
intervals = {
|
|
73
|
+
AgentState.WATCHING: 300,
|
|
74
|
+
AgentState.ALERT: 60,
|
|
75
|
+
AgentState.INCIDENT: 30,
|
|
76
|
+
AgentState.DEGRADED: 120,
|
|
77
|
+
}
|
|
78
|
+
return intervals[self.state]
|
|
79
|
+
|
|
80
|
+
def get_cache_ttl(self) -> int | None:
|
|
81
|
+
"""Cache TTL in seconds. None = no caching (INCIDENT)."""
|
|
82
|
+
ttls = {
|
|
83
|
+
AgentState.WATCHING: 900, # 15 min
|
|
84
|
+
AgentState.ALERT: 300, # 5 min
|
|
85
|
+
AgentState.INCIDENT: None, # no caching
|
|
86
|
+
AgentState.DEGRADED: 600, # 10 min
|
|
87
|
+
}
|
|
88
|
+
return ttls[self.state]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""EventStore protocol — the storage contract for SitRep."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Protocol
|
|
5
|
+
|
|
6
|
+
from nthlayer_workers.correlate.types import EventType, SitRepEvent
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EventStore(Protocol):
|
|
10
|
+
"""Protocol defining the storage interface for SitRep events."""
|
|
11
|
+
|
|
12
|
+
def insert(self, event: SitRepEvent) -> None: ...
|
|
13
|
+
|
|
14
|
+
def insert_batch(self, events: list[SitRepEvent]) -> None: ...
|
|
15
|
+
|
|
16
|
+
def get_by_time_window(
|
|
17
|
+
self,
|
|
18
|
+
start: str,
|
|
19
|
+
end: str,
|
|
20
|
+
*,
|
|
21
|
+
service: str | None = None,
|
|
22
|
+
event_type: EventType | None = None,
|
|
23
|
+
min_severity: float | None = None,
|
|
24
|
+
) -> list[SitRepEvent]: ...
|
|
25
|
+
|
|
26
|
+
def search(
|
|
27
|
+
self,
|
|
28
|
+
query: str,
|
|
29
|
+
*,
|
|
30
|
+
limit: int = 100,
|
|
31
|
+
time_window: tuple[str, str] | None = None,
|
|
32
|
+
service: str | None = None,
|
|
33
|
+
) -> list[SitRepEvent]: ...
|
|
34
|
+
|
|
35
|
+
def get_by_topology(
|
|
36
|
+
self, service: str, hops: int = 1
|
|
37
|
+
) -> list[SitRepEvent]: ...
|
|
38
|
+
|
|
39
|
+
def get_recent_changes(
|
|
40
|
+
self, service: str, window_minutes: int = 30,
|
|
41
|
+
reference_time: str | None = None,
|
|
42
|
+
) -> list[SitRepEvent]: ...
|
|
43
|
+
|
|
44
|
+
def expire_old(self) -> int: ...
|
|
45
|
+
|
|
46
|
+
def get_state_hash(self, time_window: tuple[str, str]) -> str: ...
|
|
47
|
+
|
|
48
|
+
def get_stats(self) -> dict[str, Any]: ...
|