devopsiq 0.1.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.
- agent/__init__.py +5 -0
- agent/agent.py +232 -0
- agent/investigation.py +339 -0
- agent/prompts.py +125 -0
- agent/store.py +147 -0
- devopsiq-0.1.0.dist-info/METADATA +662 -0
- devopsiq-0.1.0.dist-info/RECORD +30 -0
- devopsiq-0.1.0.dist-info/WHEEL +5 -0
- devopsiq-0.1.0.dist-info/entry_points.txt +2 -0
- devopsiq-0.1.0.dist-info/licenses/LICENSE +21 -0
- devopsiq-0.1.0.dist-info/top_level.txt +3 -0
- main.py +310 -0
- tools/__init__.py +6 -0
- tools/ansible.py +110 -0
- tools/argocd.py +91 -0
- tools/base.py +112 -0
- tools/cloud.py +101 -0
- tools/docker.py +280 -0
- tools/git_ci.py +257 -0
- tools/helm.py +168 -0
- tools/investigation.py +357 -0
- tools/istio.py +43 -0
- tools/kubernetes.py +464 -0
- tools/monitoring.py +162 -0
- tools/newrelic.py +167 -0
- tools/preflight.py +59 -0
- tools/registry.py +49 -0
- tools/system.py +162 -0
- tools/terraform.py +90 -0
- tools/trivy.py +83 -0
agent/__init__.py
ADDED
agent/agent.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Core agent: the conversation + tool-use loop to the LLM backend.
|
|
2
|
+
|
|
3
|
+
Phase 2 builds the tool-use loop on top of the Phase 1 skeleton, Phase 4
|
|
4
|
+
adds the investigation loop on top of that, and Phase 5 broadens coverage
|
|
5
|
+
from Kubernetes-only to the wider DevOps surface:
|
|
6
|
+
|
|
7
|
+
user reports a problem
|
|
8
|
+
-> the model opens an investigation (investigation_begin)
|
|
9
|
+
-> it gathers evidence with the read-only tools — Kubernetes,
|
|
10
|
+
Linux systemd/journal/journal/ports/processes, Docker containers,
|
|
11
|
+
Terraform state/plan, git/gh — tracking hypotheses and evidence in
|
|
12
|
+
a first-class record (investigation_record)
|
|
13
|
+
-> it concludes with root cause + remediation + verification
|
|
14
|
+
(investigation_conclude), ending with a structured report
|
|
15
|
+
|
|
16
|
+
The tool-use loop itself is unchanged: every model call funnels through the
|
|
17
|
+
single `_complete()` chokepoint, and the investigation tools are just three
|
|
18
|
+
more registered tools — they persist state in agent/investigation.py, which
|
|
19
|
+
mutates nothing outside the agent's own memory. The read-only guarantee
|
|
20
|
+
holds: every tool is a fixed, allowlisted argv template; no tool can touch
|
|
21
|
+
a cluster, a file, or infrastructure beyond reading it.
|
|
22
|
+
|
|
23
|
+
Phase 7 makes that record durable: every mutation is auto-saved to the
|
|
24
|
+
InvestigationStore (agent/store.py), so the investigation survives CLI
|
|
25
|
+
exits; the delegates below expose resume/list/store-location to the CLI.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
|
|
30
|
+
from openai import OpenAI
|
|
31
|
+
|
|
32
|
+
from agent.prompts import SYSTEM_PROMPT
|
|
33
|
+
from agent.store import InvestigationStore
|
|
34
|
+
from tools import ( # noqa: F401 — side effect: each module registers its tools
|
|
35
|
+
ansible,
|
|
36
|
+
argocd,
|
|
37
|
+
cloud,
|
|
38
|
+
docker,
|
|
39
|
+
git_ci,
|
|
40
|
+
helm,
|
|
41
|
+
investigation,
|
|
42
|
+
istio,
|
|
43
|
+
kubernetes,
|
|
44
|
+
monitoring,
|
|
45
|
+
newrelic,
|
|
46
|
+
preflight,
|
|
47
|
+
system,
|
|
48
|
+
terraform,
|
|
49
|
+
trivy,
|
|
50
|
+
)
|
|
51
|
+
from tools.investigation import (
|
|
52
|
+
finish_investigation,
|
|
53
|
+
list_saved_text,
|
|
54
|
+
report_json,
|
|
55
|
+
report_text,
|
|
56
|
+
resume_investigation as load_resumable_investigation,
|
|
57
|
+
set_store as set_investigation_store,
|
|
58
|
+
start_investigation,
|
|
59
|
+
status_text,
|
|
60
|
+
store_directory_text,
|
|
61
|
+
)
|
|
62
|
+
from tools.registry import execute_tool, get_tools
|
|
63
|
+
|
|
64
|
+
DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
|
|
65
|
+
DEFAULT_MODEL = "z-ai/glm-5.3"
|
|
66
|
+
PLACEHOLDER_KEY = "your_key_here"
|
|
67
|
+
|
|
68
|
+
# Safety valve: the model gets at most this many tool-use turns before we stop.
|
|
69
|
+
MAX_TOOL_ITERATIONS = 10
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class DevOpsAgent:
|
|
73
|
+
"""Minimal DevOps investigation assistant (read-only by design)."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
api_key: str | None = None,
|
|
78
|
+
model: str | None = None,
|
|
79
|
+
base_url: str | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
# Configuration resolution order: explicit argument > environment > default.
|
|
82
|
+
self.api_key = api_key or os.getenv("OPENROUTER_API_KEY")
|
|
83
|
+
if not self.api_key:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
"OPENROUTER_API_KEY is not set. Copy .env.example to .env and "
|
|
86
|
+
"fill in your key, or export OPENROUTER_API_KEY in your shell."
|
|
87
|
+
)
|
|
88
|
+
if self.api_key.strip().lower() == PLACEHOLDER_KEY:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
"OPENROUTER_API_KEY still has the placeholder value. Edit .env "
|
|
91
|
+
"and replace `your_key_here` with your real key."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
self.model = model or os.getenv("OPENROUTER_MODEL", DEFAULT_MODEL)
|
|
95
|
+
self.base_url = base_url or os.getenv("OPENROUTER_BASE_URL", DEFAULT_BASE_URL)
|
|
96
|
+
|
|
97
|
+
# OpenRouter exposes an OpenAI-compatible API, so the official OpenAI
|
|
98
|
+
# SDK is a drop-in client — just pointed at OpenRouter's base URL.
|
|
99
|
+
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
|
100
|
+
|
|
101
|
+
# Short-term conversation history. Starts with the system prompt;
|
|
102
|
+
# grows as user, model, and tool results exchange turns.
|
|
103
|
+
self.messages: list[dict] = [{"role": "system", "content": SYSTEM_PROMPT}]
|
|
104
|
+
|
|
105
|
+
# Tools the model may call this session (read-only by construction).
|
|
106
|
+
self.tools = get_tools()
|
|
107
|
+
|
|
108
|
+
def ask(self, user_message: str) -> str:
|
|
109
|
+
"""Send one user message; run the tool-use loop; return the final answer.
|
|
110
|
+
|
|
111
|
+
The message, any tool exchanges, and the final assistant reply are all
|
|
112
|
+
kept in this session's history so the model retains context across
|
|
113
|
+
turns. Raises ValueError on empty input.
|
|
114
|
+
"""
|
|
115
|
+
message = user_message.strip()
|
|
116
|
+
if not message:
|
|
117
|
+
raise ValueError("Cannot send an empty user message.")
|
|
118
|
+
|
|
119
|
+
self.messages.append({"role": "user", "content": message})
|
|
120
|
+
reply = self._complete()
|
|
121
|
+
self.messages.append({"role": "assistant", "content": reply})
|
|
122
|
+
return reply
|
|
123
|
+
|
|
124
|
+
# --- investigation lifecycle (Phase 4) — thin CLI-facing delegates --------
|
|
125
|
+
#
|
|
126
|
+
# The investigation record itself lives in agent/investigation.py; the
|
|
127
|
+
# model drives it via the investigation_* tools inside the normal loop.
|
|
128
|
+
# These methods let the CLI inspect and control the same state (/investigate,
|
|
129
|
+
# /investigation, /report, /endinvestigation).
|
|
130
|
+
|
|
131
|
+
def begin_investigation(self, problem: str) -> str:
|
|
132
|
+
"""Open a formal investigation for `problem` (the /investigate command)."""
|
|
133
|
+
return start_investigation(problem)
|
|
134
|
+
|
|
135
|
+
def investigation_status_text(self) -> str | None:
|
|
136
|
+
"""Live tracker text, or None when no investigation is active."""
|
|
137
|
+
return status_text()
|
|
138
|
+
|
|
139
|
+
def investigation_report_text(self) -> str | None:
|
|
140
|
+
"""Canonical report once concluded (tracker while in progress), or None."""
|
|
141
|
+
return report_text()
|
|
142
|
+
|
|
143
|
+
def investigation_report_json(self) -> dict | None:
|
|
144
|
+
"""Structured JSON export of the investigation (for CI/automation), or None."""
|
|
145
|
+
return report_json()
|
|
146
|
+
|
|
147
|
+
def end_investigation(self) -> str:
|
|
148
|
+
"""Clear the active investigation (read-only: discards only memory)."""
|
|
149
|
+
return finish_investigation()
|
|
150
|
+
|
|
151
|
+
# --- persistence (Phase 7) — thin CLI-facing delegates ---------------------
|
|
152
|
+
#
|
|
153
|
+
# The record is auto-saved by the mutation chokepoint in
|
|
154
|
+
# tools/investigation.py; these methods expose resume/list/store-location
|
|
155
|
+
# to the CLI (REPL startup auto-resume, /investigations, --store-dir).
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def store_dir(self) -> str | None:
|
|
159
|
+
"""Directory records are saved to, or None when persistence is off."""
|
|
160
|
+
return store_directory_text()
|
|
161
|
+
|
|
162
|
+
def list_investigations(self) -> str | None:
|
|
163
|
+
"""Saved investigation records, newest first, or None if none."""
|
|
164
|
+
return list_saved_text()
|
|
165
|
+
|
|
166
|
+
def resume_investigation(self) -> str | None:
|
|
167
|
+
"""Continue the newest in-progress record, or None if there is none."""
|
|
168
|
+
return load_resumable_investigation()
|
|
169
|
+
|
|
170
|
+
def use_store_dir(self, directory: str) -> None:
|
|
171
|
+
"""Point persistence at `directory` (--store-dir; tests use tmpdirs)."""
|
|
172
|
+
set_investigation_store(InvestigationStore(directory))
|
|
173
|
+
|
|
174
|
+
def _complete(self) -> str:
|
|
175
|
+
"""The tool-use loop — the single chokepoint where the backend is called.
|
|
176
|
+
|
|
177
|
+
Each turn sends the full history (with tool schemas attached when tools
|
|
178
|
+
are available). If the reply requests tools, every requested tool is
|
|
179
|
+
executed locally, the results are appended, and the loop calls the
|
|
180
|
+
model again. Stops when the model answers in plain text, or when the
|
|
181
|
+
iteration cap is reached.
|
|
182
|
+
"""
|
|
183
|
+
for _ in range(MAX_TOOL_ITERATIONS):
|
|
184
|
+
request: dict = {"model": self.model, "messages": self.messages}
|
|
185
|
+
if self.tools:
|
|
186
|
+
request["tools"] = [tool.schema() for tool in self.tools]
|
|
187
|
+
|
|
188
|
+
response = self.client.chat.completions.create(**request)
|
|
189
|
+
message = response.choices[0].message
|
|
190
|
+
|
|
191
|
+
if not message.tool_calls:
|
|
192
|
+
content = message.content
|
|
193
|
+
if content is None:
|
|
194
|
+
raise RuntimeError("The model returned an empty response.")
|
|
195
|
+
return content
|
|
196
|
+
|
|
197
|
+
# Echo the assistant tool-request turn verbatim, then answer each
|
|
198
|
+
# call with a sibling "tool" message referencing its call id.
|
|
199
|
+
self.messages.append(_echo_tool_request(message))
|
|
200
|
+
for call in message.tool_calls:
|
|
201
|
+
result = execute_tool(call.function.name, call.function.arguments)
|
|
202
|
+
self.messages.append(
|
|
203
|
+
{"role": "tool", "tool_call_id": call.id, "content": result}
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
raise RuntimeError(
|
|
207
|
+
f"The model did not finish after {MAX_TOOL_ITERATIONS} tool-use turns."
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _echo_tool_request(message) -> dict:
|
|
212
|
+
"""Rebuild the assistant turn that requested tools, verbatim.
|
|
213
|
+
|
|
214
|
+
The OpenAI-compatible API requires the assistant tool-request turn to be
|
|
215
|
+
echoed into history with the exact tool_calls, each one answered by a
|
|
216
|
+
sibling {"role": "tool"} message that references it via tool_call_id.
|
|
217
|
+
"""
|
|
218
|
+
return {
|
|
219
|
+
"role": "assistant",
|
|
220
|
+
"content": message.content or "",
|
|
221
|
+
"tool_calls": [
|
|
222
|
+
{
|
|
223
|
+
"id": call.id,
|
|
224
|
+
"type": "function",
|
|
225
|
+
"function": {
|
|
226
|
+
"name": call.function.name,
|
|
227
|
+
"arguments": call.function.arguments,
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
for call in message.tool_calls
|
|
231
|
+
],
|
|
232
|
+
}
|
agent/investigation.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Investigation state for the DevOps agent (Phase 4).
|
|
2
|
+
|
|
3
|
+
A first-class, in-memory record of an ongoing incident investigation: the
|
|
4
|
+
problem statement, tracked hypotheses with verdicts, evidence notes, and —
|
|
5
|
+
once concluded — root cause, remediation, and verification.
|
|
6
|
+
|
|
7
|
+
The model drives this state through the investigation_* tools in
|
|
8
|
+
tools/investigation.py; the CLI exposes the same state via /investigate,
|
|
9
|
+
/investigation and /report. This module is PURE DATA — no model calls, no
|
|
10
|
+
I/O — so it is fully unit-testable.
|
|
11
|
+
|
|
12
|
+
Read-only guarantee: recording a hypothesis or a verdict mutates only this
|
|
13
|
+
agent's in-memory state. Nothing here touches a cluster, a file, or any
|
|
14
|
+
real system.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
|
|
21
|
+
HYPOTHESIS_STATUSES = ("proposed", "supported", "refuted", "confirmed")
|
|
22
|
+
CONFIDENCE_LEVELS = ("high", "medium", "low")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InvestigationError(Exception):
|
|
26
|
+
"""Raised on invalid state transitions (e.g. unknown hypothesis id)."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Hypothesis:
|
|
31
|
+
id: str
|
|
32
|
+
statement: str
|
|
33
|
+
status: str = "proposed"
|
|
34
|
+
notes: list[str] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
def set_status(self, status: str) -> None:
|
|
37
|
+
if status not in HYPOTHESIS_STATUSES:
|
|
38
|
+
raise InvestigationError(
|
|
39
|
+
f"invalid hypothesis status {status!r}; expected one of "
|
|
40
|
+
f"{HYPOTHESIS_STATUSES}"
|
|
41
|
+
)
|
|
42
|
+
self.status = status
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class EvidenceNote:
|
|
47
|
+
id: str
|
|
48
|
+
content: str
|
|
49
|
+
hypothesis_id: str | None = None # which tracked hypothesis this bears on
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Conclusion:
|
|
54
|
+
summary: str
|
|
55
|
+
root_cause: str
|
|
56
|
+
remediation: list[str]
|
|
57
|
+
verification: list[str]
|
|
58
|
+
confidence: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Investigation:
|
|
62
|
+
"""Tracks one incident investigation from problem statement to report."""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
problem: str,
|
|
67
|
+
initial_hypotheses: list[str] | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
if not problem or not problem.strip():
|
|
70
|
+
raise InvestigationError("problem statement must not be empty")
|
|
71
|
+
self.problem = problem.strip()
|
|
72
|
+
self.hypotheses: list[Hypothesis] = []
|
|
73
|
+
self.evidence: list[EvidenceNote] = []
|
|
74
|
+
self.conclusion: Conclusion | None = None
|
|
75
|
+
self._next_h = 1
|
|
76
|
+
self._next_e = 1
|
|
77
|
+
for statement in initial_hypotheses or []:
|
|
78
|
+
if statement and statement.strip():
|
|
79
|
+
self.add_hypothesis(statement)
|
|
80
|
+
|
|
81
|
+
# --- mutations; each returns a short confirmation for the tool result ---
|
|
82
|
+
|
|
83
|
+
def add_hypothesis(self, statement: str) -> str:
|
|
84
|
+
clean = (statement or "").strip()
|
|
85
|
+
if not clean:
|
|
86
|
+
raise InvestigationError("hypothesis statement must not be empty")
|
|
87
|
+
h = Hypothesis(id=f"H{self._next_h}", statement=clean)
|
|
88
|
+
self._next_h += 1
|
|
89
|
+
self.hypotheses.append(h)
|
|
90
|
+
return f"recorded hypothesis {h.id}: {clean}"
|
|
91
|
+
|
|
92
|
+
def verify_hypothesis(
|
|
93
|
+
self, hypothesis_id: str, status: str, note: str | None = None
|
|
94
|
+
) -> str:
|
|
95
|
+
h = self._get_hypothesis(hypothesis_id)
|
|
96
|
+
h.set_status(status)
|
|
97
|
+
if note and note.strip():
|
|
98
|
+
h.notes.append(note.strip())
|
|
99
|
+
detail = f" — {note.strip()}" if note and note.strip() else ""
|
|
100
|
+
return f"{h.id} is now [{h.status}]: {h.statement}{detail}"
|
|
101
|
+
|
|
102
|
+
def record_evidence(self, content: str, hypothesis_id: str | None = None) -> str:
|
|
103
|
+
clean = (content or "").strip()
|
|
104
|
+
if not clean:
|
|
105
|
+
raise InvestigationError("evidence note must not be empty")
|
|
106
|
+
if hypothesis_id is not None:
|
|
107
|
+
self._get_hypothesis(hypothesis_id) # validates the link target
|
|
108
|
+
note = EvidenceNote(
|
|
109
|
+
id=f"E{self._next_e}", content=clean, hypothesis_id=hypothesis_id
|
|
110
|
+
)
|
|
111
|
+
self._next_e += 1
|
|
112
|
+
self.evidence.append(note)
|
|
113
|
+
link = f" (→ {hypothesis_id})" if hypothesis_id else ""
|
|
114
|
+
return f"recorded evidence {note.id}: {clean}{link}"
|
|
115
|
+
|
|
116
|
+
def conclude(
|
|
117
|
+
self,
|
|
118
|
+
*,
|
|
119
|
+
summary: str,
|
|
120
|
+
root_cause: str,
|
|
121
|
+
remediation: str | list[str],
|
|
122
|
+
verification: str | list[str],
|
|
123
|
+
confidence: str = "medium",
|
|
124
|
+
) -> str:
|
|
125
|
+
if self.conclusion is not None:
|
|
126
|
+
return "this investigation is already concluded — /report shows it"
|
|
127
|
+
if not (summary or "").strip() or not (root_cause or "").strip():
|
|
128
|
+
raise InvestigationError("summary and root_cause are required")
|
|
129
|
+
if confidence not in CONFIDENCE_LEVELS:
|
|
130
|
+
raise InvestigationError(
|
|
131
|
+
f"invalid confidence {confidence!r}; expected one of {CONFIDENCE_LEVELS}"
|
|
132
|
+
)
|
|
133
|
+
self.conclusion = Conclusion(
|
|
134
|
+
summary=summary.strip(),
|
|
135
|
+
root_cause=root_cause.strip(),
|
|
136
|
+
remediation=_string_list(remediation, "remediation"),
|
|
137
|
+
verification=_string_list(verification, "verification"),
|
|
138
|
+
confidence=confidence,
|
|
139
|
+
)
|
|
140
|
+
return (
|
|
141
|
+
"investigation concluded. End your answer with the structured "
|
|
142
|
+
"report; it is also available via /report."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# --- read-only views for the model and the CLI ---
|
|
146
|
+
|
|
147
|
+
def render_status(self) -> str:
|
|
148
|
+
"""Compact tracker appended to every investigation tool result."""
|
|
149
|
+
lines = ["**Investigation tracker**", f"Problem: {self.problem}"]
|
|
150
|
+
if self.conclusion is None:
|
|
151
|
+
lines.append("Status: in progress")
|
|
152
|
+
else:
|
|
153
|
+
lines.append(f"Status: concluded (confidence {self.conclusion.confidence})")
|
|
154
|
+
if self.hypotheses:
|
|
155
|
+
lines.append("Hypotheses:")
|
|
156
|
+
lines += [f"- {h.id} [{h.status}] {h.statement}" for h in self.hypotheses]
|
|
157
|
+
else:
|
|
158
|
+
lines.append("Hypotheses: none yet")
|
|
159
|
+
if self.evidence:
|
|
160
|
+
lines.append("Evidence:")
|
|
161
|
+
lines += [
|
|
162
|
+
f"- {e.id} {e.content}"
|
|
163
|
+
+ (f" (→ {e.hypothesis_id})" if e.hypothesis_id else "")
|
|
164
|
+
for e in self.evidence
|
|
165
|
+
]
|
|
166
|
+
else:
|
|
167
|
+
lines.append("Evidence: none recorded yet")
|
|
168
|
+
return "\n".join(lines)
|
|
169
|
+
|
|
170
|
+
def render_report(self) -> str:
|
|
171
|
+
"""Canonical final report (deterministic — always rendered from state)."""
|
|
172
|
+
if self.conclusion is None:
|
|
173
|
+
return self.render_status()
|
|
174
|
+
|
|
175
|
+
c = self.conclusion
|
|
176
|
+
lines = [
|
|
177
|
+
"# Investigation report",
|
|
178
|
+
"",
|
|
179
|
+
f"**Problem:** {self.problem}",
|
|
180
|
+
"",
|
|
181
|
+
"## Facts / evidence",
|
|
182
|
+
]
|
|
183
|
+
if self.evidence:
|
|
184
|
+
lines += [
|
|
185
|
+
f"- {e.content}"
|
|
186
|
+
+ (f" (→ {e.hypothesis_id})" if e.hypothesis_id else "")
|
|
187
|
+
for e in self.evidence
|
|
188
|
+
]
|
|
189
|
+
else:
|
|
190
|
+
lines.append("- (none recorded)")
|
|
191
|
+
lines += ["", "## Hypotheses"]
|
|
192
|
+
if self.hypotheses:
|
|
193
|
+
lines += [
|
|
194
|
+
f"- **{h.id}** [{h.status}] {h.statement}" for h in self.hypotheses
|
|
195
|
+
]
|
|
196
|
+
else:
|
|
197
|
+
lines.append("- (none)")
|
|
198
|
+
lines += ["", "## Root cause", "", c.root_cause]
|
|
199
|
+
lines += ["", "## Remediation (recommendations — nothing was executed)"]
|
|
200
|
+
lines += [f"- {item}" for item in c.remediation] or ["- (none)"]
|
|
201
|
+
lines += ["", "## Verification steps"]
|
|
202
|
+
lines += [f"- {item}" for item in c.verification] or ["- (none)"]
|
|
203
|
+
lines += [
|
|
204
|
+
"",
|
|
205
|
+
f"**Summary:** {c.summary}",
|
|
206
|
+
f"**Confidence:** {c.confidence}",
|
|
207
|
+
]
|
|
208
|
+
return "\n".join(lines)
|
|
209
|
+
|
|
210
|
+
def render_report_json(self) -> dict:
|
|
211
|
+
"""Structured export of the investigation, for CI/automation tooling.
|
|
212
|
+
|
|
213
|
+
Always returns the same shape whether or not the investigation is
|
|
214
|
+
concluded: hypotheses and evidence are lists; `conclusion` is present
|
|
215
|
+
only once concluded. No model text is trusted — everything is
|
|
216
|
+
re-derived deterministically from the tracked record.
|
|
217
|
+
"""
|
|
218
|
+
out: dict = {
|
|
219
|
+
"problem": self.problem,
|
|
220
|
+
"status": "concluded" if self.conclusion is not None else "in_progress",
|
|
221
|
+
"hypotheses": [
|
|
222
|
+
{
|
|
223
|
+
"id": h.id,
|
|
224
|
+
"statement": h.statement,
|
|
225
|
+
"status": h.status,
|
|
226
|
+
"notes": list(h.notes),
|
|
227
|
+
}
|
|
228
|
+
for h in self.hypotheses
|
|
229
|
+
],
|
|
230
|
+
"evidence": [
|
|
231
|
+
{
|
|
232
|
+
"id": e.id,
|
|
233
|
+
"content": e.content,
|
|
234
|
+
"hypothesis_id": e.hypothesis_id,
|
|
235
|
+
}
|
|
236
|
+
for e in self.evidence
|
|
237
|
+
],
|
|
238
|
+
}
|
|
239
|
+
if self.conclusion is not None:
|
|
240
|
+
c = self.conclusion
|
|
241
|
+
out["conclusion"] = {
|
|
242
|
+
"summary": c.summary,
|
|
243
|
+
"root_cause": c.root_cause,
|
|
244
|
+
"remediation": list(c.remediation),
|
|
245
|
+
"verification": list(c.verification),
|
|
246
|
+
"confidence": c.confidence,
|
|
247
|
+
}
|
|
248
|
+
return out
|
|
249
|
+
|
|
250
|
+
# --- persistence (Phase 7) — lossless round-trip for the store ------------
|
|
251
|
+
|
|
252
|
+
def to_dict(self, *, saved_at: str | None = None) -> dict:
|
|
253
|
+
"""Full serializable state, for the store to write to disk.
|
|
254
|
+
|
|
255
|
+
render_report_json() is already a lossless view of the record, so
|
|
256
|
+
persistence reuses it and adds storage metadata: `schema` (format
|
|
257
|
+
version, bumped only on breaking changes) and `saved_at` (ISO
|
|
258
|
+
timestamp, set by the store). from_dict() round-trips this exactly.
|
|
259
|
+
"""
|
|
260
|
+
out = self.render_report_json()
|
|
261
|
+
out["schema"] = 1
|
|
262
|
+
if saved_at is not None:
|
|
263
|
+
out["saved_at"] = saved_at
|
|
264
|
+
return out
|
|
265
|
+
|
|
266
|
+
@classmethod
|
|
267
|
+
def from_dict(cls, data: dict) -> "Investigation":
|
|
268
|
+
"""Rebuild an Investigation from to_dict() output.
|
|
269
|
+
|
|
270
|
+
Tolerant of unknown keys (forward compatible) and strict about our
|
|
271
|
+
own: a corrupt or truncated file raises InvestigationError (or
|
|
272
|
+
KeyError for a missing problem), which the store turns into "skip
|
|
273
|
+
this file" rather than a crash.
|
|
274
|
+
"""
|
|
275
|
+
inv = cls(data["problem"])
|
|
276
|
+
for h in data.get("hypotheses") or []:
|
|
277
|
+
hyp = Hypothesis(
|
|
278
|
+
id=str(h.get("id", "")),
|
|
279
|
+
statement=str(h.get("statement", "")),
|
|
280
|
+
status="proposed",
|
|
281
|
+
notes=[str(n) for n in (h.get("notes") or [])],
|
|
282
|
+
)
|
|
283
|
+
hyp.set_status(str(h.get("status", "proposed")))
|
|
284
|
+
number = _id_number(hyp.id, "H")
|
|
285
|
+
if number is None:
|
|
286
|
+
raise InvestigationError(f"malformed hypothesis id {hyp.id!r}")
|
|
287
|
+
inv.hypotheses.append(hyp)
|
|
288
|
+
if number >= inv._next_h:
|
|
289
|
+
inv._next_h = number + 1
|
|
290
|
+
for e in data.get("evidence") or []:
|
|
291
|
+
hypothesis_id = e.get("hypothesis_id")
|
|
292
|
+
note = EvidenceNote(
|
|
293
|
+
id=str(e.get("id", "")),
|
|
294
|
+
content=str(e.get("content", "")),
|
|
295
|
+
hypothesis_id=str(hypothesis_id) if hypothesis_id else None,
|
|
296
|
+
)
|
|
297
|
+
number = _id_number(note.id, "E")
|
|
298
|
+
if number is None:
|
|
299
|
+
raise InvestigationError(f"malformed evidence id {note.id!r}")
|
|
300
|
+
if note.hypothesis_id is not None:
|
|
301
|
+
inv._get_hypothesis(note.hypothesis_id) # validate the link
|
|
302
|
+
inv.evidence.append(note)
|
|
303
|
+
if number >= inv._next_e:
|
|
304
|
+
inv._next_e = number + 1
|
|
305
|
+
c = data.get("conclusion")
|
|
306
|
+
if c is not None:
|
|
307
|
+
inv.conclude(
|
|
308
|
+
summary=str(c.get("summary", "")),
|
|
309
|
+
root_cause=str(c.get("root_cause", "")),
|
|
310
|
+
remediation=c.get("remediation") or [],
|
|
311
|
+
verification=c.get("verification") or [],
|
|
312
|
+
confidence=str(c.get("confidence", "medium")),
|
|
313
|
+
)
|
|
314
|
+
return inv
|
|
315
|
+
|
|
316
|
+
def _get_hypothesis(self, hypothesis_id: str) -> Hypothesis:
|
|
317
|
+
found = next((h for h in self.hypotheses if h.id == hypothesis_id), None)
|
|
318
|
+
if found is None:
|
|
319
|
+
raise InvestigationError(
|
|
320
|
+
f"unknown hypothesis id {hypothesis_id!r}; known ids: "
|
|
321
|
+
f"{', '.join(h.id for h in self.hypotheses) or 'none'}"
|
|
322
|
+
)
|
|
323
|
+
return found
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _string_list(value: str | list[str], label: str) -> list[str]:
|
|
327
|
+
items = [value] if isinstance(value, str) else list(value or [])
|
|
328
|
+
cleaned = [str(item).strip() for item in items if str(item).strip()]
|
|
329
|
+
if not cleaned:
|
|
330
|
+
raise InvestigationError(f"{label} must contain at least one item")
|
|
331
|
+
return cleaned
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _id_number(value: str, prefix: str) -> int | None:
|
|
335
|
+
"""Numeric suffix of an H*/E* id (H12 -> 12), or None if malformed."""
|
|
336
|
+
if not value.startswith(prefix):
|
|
337
|
+
return None
|
|
338
|
+
tail = value[len(prefix):]
|
|
339
|
+
return int(tail) if tail.isdigit() else None
|