Jonathan Haas commited on
Commit
39e5a0d
·
1 Parent(s): 6bf839c

Add taxonomy docs and richer telemetry error accounting

Browse files
README.md CHANGED
@@ -92,6 +92,7 @@ Smart home safety defaults:
92
  - Integration runbook: [`docs/operations/integration-policy.md`](docs/operations/integration-policy.md).
93
  - Release checklist: [`docs/operations/release-checklist.md`](docs/operations/release-checklist.md).
94
  - Security maintenance: [`docs/operations/security-maintenance.md`](docs/operations/security-maintenance.md).
 
95
  - Todoist integration:
96
  - `TODOIST_PERMISSION_PROFILE=readonly|control`
97
  - `readonly` allows `todoist_list_tasks` and denies `todoist_add_task`
 
92
  - Integration runbook: [`docs/operations/integration-policy.md`](docs/operations/integration-policy.md).
93
  - Release checklist: [`docs/operations/release-checklist.md`](docs/operations/release-checklist.md).
94
  - Security maintenance: [`docs/operations/security-maintenance.md`](docs/operations/security-maintenance.md).
95
+ - Error taxonomy: [`docs/operations/error-taxonomy.md`](docs/operations/error-taxonomy.md).
96
  - Todoist integration:
97
  - `TODOIST_PERMISSION_PROFILE=readonly|control`
98
  - `readonly` allows `todoist_list_tasks` and denies `todoist_add_task`
TODO.md CHANGED
@@ -65,14 +65,14 @@ Files: `src/jarvis/tools/services.py`, `tests/test_tools.py`, `docs/operations/h
65
 
66
  ## 4) Telemetry and Taxonomy (8 items)
67
 
68
- - [ ] `T01` Add taxonomy reference doc with each error code and owning tool families.
69
- - [ ] `T02` Add test asserting taxonomy doc examples stay in sync with constants.
70
- - [ ] `T03` Track unknown summary detail count in telemetry snapshot.
71
- - [ ] `T04` Add tests for unknown summary detail accounting.
72
- - [ ] `T05` Add explicit counter for per-code service error totals in telemetry snapshot.
73
- - [ ] `T06` Add tests for per-code aggregation stability.
74
- - [ ] `T07` Add NaN/inf guard tests for telemetry averages.
75
- - [ ] `T08` Add startup log line summarizing taxonomy sizes (service/storage subsets).
76
 
77
  Files: `src/jarvis/tool_errors.py`, `src/jarvis/__main__.py`, `tests/test_main_lifecycle.py`, `docs/operations/error-taxonomy.md`
78
 
 
65
 
66
  ## 4) Telemetry and Taxonomy (8 items)
67
 
68
+ - [x] `T01` Add taxonomy reference doc with each error code and owning tool families.
69
+ - [x] `T02` Add test asserting taxonomy doc examples stay in sync with constants.
70
+ - [x] `T03` Track unknown summary detail count in telemetry snapshot.
71
+ - [x] `T04` Add tests for unknown summary detail accounting.
72
+ - [x] `T05` Add explicit counter for per-code service error totals in telemetry snapshot.
73
+ - [x] `T06` Add tests for per-code aggregation stability.
74
+ - [x] `T07` Add NaN/inf guard tests for telemetry averages.
75
+ - [x] `T08` Add startup log line summarizing taxonomy sizes (service/storage subsets).
76
 
77
  Files: `src/jarvis/tool_errors.py`, `src/jarvis/__main__.py`, `tests/test_main_lifecycle.py`, `docs/operations/error-taxonomy.md`
78
 
docs/operations/error-taxonomy.md ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Error Taxonomy Reference
2
+
3
+ Canonical tool error taxonomy shared by service tools and telemetry.
4
+
5
+ ## Ownership by Tool Family
6
+
7
+ | Error code | Owning tool families |
8
+ |---|---|
9
+ | `api_error` | `pushover_notify` |
10
+ | `auth` | `smart_home`, `smart_home_state`, `todoist_*`, `pushover_notify` |
11
+ | `cancelled` | `smart_home*`, `todoist_*`, `pushover_notify` |
12
+ | `http_error` | `smart_home*`, `todoist_*`, `pushover_notify` |
13
+ | `invalid_data` | `smart_home`, `task_plan_*`, `memory_*` |
14
+ | `invalid_json` | `smart_home*`, `todoist_*`, `pushover_notify` |
15
+ | `invalid_plan` | `task_plan_*` |
16
+ | `invalid_status` | `task_plan_update` |
17
+ | `invalid_steps` | `task_plan_create` |
18
+ | `missing_config` | `smart_home*`, `todoist_*`, `pushover_notify` |
19
+ | `missing_entity` | `smart_home_state` |
20
+ | `missing_fields` | `smart_home`, `todoist_add_task`, `pushover_notify` |
21
+ | `missing_plan` | `task_plan_*` |
22
+ | `missing_query` | `memory_search` |
23
+ | `missing_store` | `memory_*`, `task_plan_*` |
24
+ | `missing_text` | `memory_add` |
25
+ | `network_client_error` | `smart_home*`, `todoist_*`, `pushover_notify` |
26
+ | `not_found` | `smart_home*`, `task_plan_*` |
27
+ | `policy` | cross-cutting tool policy enforcement |
28
+ | `storage_error` | `memory_*`, `task_plan_*` |
29
+ | `summary_unavailable` | `tool_summary*`, `system_status` |
30
+ | `timeout` | `smart_home*`, `todoist_*`, `pushover_notify` |
31
+ | `unexpected` | cross-cutting runtime guard |
32
+ | `unknown_error` | fallback normalization bucket |
33
+
34
+ ## Canonical Code Set (Machine-Checked)
35
+
36
+ Do not edit this list without updating `src/jarvis/tool_errors.py` and tests.
37
+
38
+ <!-- SERVICE_ERROR_CODES_START -->
39
+ api_error
40
+ auth
41
+ cancelled
42
+ http_error
43
+ invalid_data
44
+ invalid_json
45
+ invalid_plan
46
+ invalid_status
47
+ invalid_steps
48
+ missing_config
49
+ missing_entity
50
+ missing_fields
51
+ missing_plan
52
+ missing_query
53
+ missing_store
54
+ missing_text
55
+ network_client_error
56
+ not_found
57
+ policy
58
+ storage_error
59
+ summary_unavailable
60
+ timeout
61
+ unexpected
62
+ unknown_error
63
+ <!-- SERVICE_ERROR_CODES_END -->
src/jarvis/__main__.py CHANGED
@@ -193,8 +193,10 @@ class Jarvis:
193
  "tts_first_audio_count": 0.0,
194
  "service_errors": 0.0,
195
  "storage_errors": 0.0,
 
196
  "fallback_responses": 0.0,
197
  }
 
198
 
199
  def start(self) -> None:
200
  """Initialize all subsystems."""
@@ -266,6 +268,7 @@ class Jarvis:
266
  f"Persona style: {self.config.persona_style}",
267
  f"Config warnings: {warning_count}",
268
  f"Tool policy: allow={len(self.config.tool_allowlist)} deny={len(self.config.tool_denylist)}",
 
269
  ]
270
 
271
  def _refresh_tool_error_counters(self) -> None:
@@ -275,6 +278,8 @@ class Jarvis:
275
  return
276
  service_errors = 0
277
  storage_errors = 0
 
 
278
  for item in recent:
279
  if not isinstance(item, dict):
280
  continue
@@ -282,30 +287,53 @@ class Jarvis:
282
  detail = str(item.get("detail", ""))
283
  if status != "error":
284
  continue
 
 
285
  if detail in TELEMETRY_STORAGE_ERROR_DETAILS:
286
  storage_errors += 1
287
  continue
288
  if detail in TELEMETRY_SERVICE_ERROR_DETAILS:
289
  service_errors += 1
 
 
290
  self._telemetry["service_errors"] = float(service_errors)
291
  self._telemetry["storage_errors"] = float(storage_errors)
 
 
 
 
 
 
 
 
 
292
 
293
- def _telemetry_snapshot(self) -> dict[str, float]:
294
  def avg(total_key: str, count_key: str) -> float:
295
- count = self._telemetry.get(count_key, 0.0)
296
- if count <= 0:
297
  return 0.0
298
- return self._telemetry.get(total_key, 0.0) / count
 
 
 
 
299
 
 
 
 
 
 
300
  return {
301
- "turns": self._telemetry.get("turns", 0.0),
302
- "barge_ins": self._telemetry.get("barge_ins", 0.0),
303
  "avg_stt_latency_ms": avg("stt_latency_total_ms", "stt_latency_count"),
304
  "avg_llm_first_sentence_ms": avg("llm_first_sentence_total_ms", "llm_first_sentence_count"),
305
  "avg_tts_first_audio_ms": avg("tts_first_audio_total_ms", "tts_first_audio_count"),
306
- "service_errors": self._telemetry.get("service_errors", 0.0),
307
- "storage_errors": self._telemetry.get("storage_errors", 0.0),
308
- "fallback_responses": self._telemetry.get("fallback_responses", 0.0),
 
 
309
  }
310
 
311
  def stop(self) -> None:
 
193
  "tts_first_audio_count": 0.0,
194
  "service_errors": 0.0,
195
  "storage_errors": 0.0,
196
+ "unknown_summary_details": 0.0,
197
  "fallback_responses": 0.0,
198
  }
199
+ self._telemetry_error_counts: dict[str, float] = {}
200
 
201
  def start(self) -> None:
202
  """Initialize all subsystems."""
 
268
  f"Persona style: {self.config.persona_style}",
269
  f"Config warnings: {warning_count}",
270
  f"Tool policy: allow={len(self.config.tool_allowlist)} deny={len(self.config.tool_denylist)}",
271
+ f"Error taxonomy: total={len(TOOL_SERVICE_ERROR_CODES)} service={len(TELEMETRY_SERVICE_ERROR_DETAILS)} storage={len(TELEMETRY_STORAGE_ERROR_DETAILS)}",
272
  ]
273
 
274
  def _refresh_tool_error_counters(self) -> None:
 
278
  return
279
  service_errors = 0
280
  storage_errors = 0
281
+ unknown_summary_details = 0
282
+ per_code: dict[str, float] = {}
283
  for item in recent:
284
  if not isinstance(item, dict):
285
  continue
 
287
  detail = str(item.get("detail", ""))
288
  if status != "error":
289
  continue
290
+ if detail in TOOL_SERVICE_ERROR_CODES:
291
+ per_code[detail] = per_code.get(detail, 0.0) + 1.0
292
  if detail in TELEMETRY_STORAGE_ERROR_DETAILS:
293
  storage_errors += 1
294
  continue
295
  if detail in TELEMETRY_SERVICE_ERROR_DETAILS:
296
  service_errors += 1
297
+ continue
298
+ unknown_summary_details += 1
299
  self._telemetry["service_errors"] = float(service_errors)
300
  self._telemetry["storage_errors"] = float(storage_errors)
301
+ self._telemetry["unknown_summary_details"] = float(unknown_summary_details)
302
+ self._telemetry_error_counts = {name: per_code[name] for name in sorted(per_code)}
303
+
304
+ def _telemetry_snapshot(self) -> dict[str, float | dict[str, float]]:
305
+ def metric(key: str) -> float:
306
+ value = self._telemetry.get(key, 0.0)
307
+ if not math.isfinite(value):
308
+ return 0.0
309
+ return value
310
 
 
311
  def avg(total_key: str, count_key: str) -> float:
312
+ count = metric(count_key)
313
+ if count <= 0.0:
314
  return 0.0
315
+ total = metric(total_key)
316
+ value = total / count
317
+ if not math.isfinite(value):
318
+ return 0.0
319
+ return value
320
 
321
+ counts = {
322
+ name: value
323
+ for name, value in getattr(self, "_telemetry_error_counts", {}).items()
324
+ if math.isfinite(value)
325
+ }
326
  return {
327
+ "turns": metric("turns"),
328
+ "barge_ins": metric("barge_ins"),
329
  "avg_stt_latency_ms": avg("stt_latency_total_ms", "stt_latency_count"),
330
  "avg_llm_first_sentence_ms": avg("llm_first_sentence_total_ms", "llm_first_sentence_count"),
331
  "avg_tts_first_audio_ms": avg("tts_first_audio_total_ms", "tts_first_audio_count"),
332
+ "service_errors": metric("service_errors"),
333
+ "storage_errors": metric("storage_errors"),
334
+ "unknown_summary_details": metric("unknown_summary_details"),
335
+ "service_error_counts": counts,
336
+ "fallback_responses": metric("fallback_responses"),
337
  }
338
 
339
  def stop(self) -> None:
tests/test_main_lifecycle.py CHANGED
@@ -1,6 +1,7 @@
1
  """Lifecycle robustness tests for jarvis.__main__.Jarvis."""
2
 
3
  import pytest
 
4
  from types import SimpleNamespace
5
  from unittest.mock import MagicMock, AsyncMock
6
 
@@ -79,6 +80,21 @@ def test_startup_summary_lines_include_core_status():
79
  assert "Memory: enabled" in joined
80
  assert "Config warnings: 0" in joined
81
  assert "Tool policy: allow=1 deny=2" in joined
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
 
84
  def test_telemetry_snapshot_averages():
@@ -104,6 +120,7 @@ def test_telemetry_snapshot_averages():
104
  assert snapshot["avg_tts_first_audio_ms"] == 250.0
105
  assert snapshot["service_errors"] == 3.0
106
  assert snapshot["storage_errors"] == 1.0
 
107
  assert snapshot["fallback_responses"] == 4.0
108
 
109
 
@@ -121,6 +138,7 @@ def test_refresh_tool_error_counters():
121
 
122
  assert jarvis._telemetry["service_errors"] == 1.0
123
  assert jarvis._telemetry["storage_errors"] == 1.0
 
124
 
125
 
126
  def test_refresh_tool_error_counters_classifies_missing_store_as_storage():
@@ -137,6 +155,7 @@ def test_refresh_tool_error_counters_classifies_missing_store_as_storage():
137
 
138
  assert jarvis._telemetry["service_errors"] == 1.0
139
  assert jarvis._telemetry["storage_errors"] == 2.0
 
140
 
141
 
142
  def test_refresh_tool_error_counters_includes_network_taxonomy():
@@ -153,6 +172,80 @@ def test_refresh_tool_error_counters_includes_network_taxonomy():
153
 
154
  assert jarvis._telemetry["service_errors"] == 3.0
155
  assert jarvis._telemetry["storage_errors"] == 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
 
158
  def test_telemetry_error_taxonomy_matches_service_error_codes():
 
1
  """Lifecycle robustness tests for jarvis.__main__.Jarvis."""
2
 
3
  import pytest
4
+ from pathlib import Path
5
  from types import SimpleNamespace
6
  from unittest.mock import MagicMock, AsyncMock
7
 
 
80
  assert "Memory: enabled" in joined
81
  assert "Config warnings: 0" in joined
82
  assert "Tool policy: allow=1 deny=2" in joined
83
+ assert "Error taxonomy: total=" in joined
84
+
85
+
86
+ def test_error_taxonomy_doc_matches_constants():
87
+ from jarvis.tool_errors import TOOL_SERVICE_ERROR_CODES
88
+
89
+ doc_path = Path(__file__).resolve().parents[1] / "docs" / "operations" / "error-taxonomy.md"
90
+ text = doc_path.read_text(encoding="utf-8")
91
+ start = "<!-- SERVICE_ERROR_CODES_START -->"
92
+ end = "<!-- SERVICE_ERROR_CODES_END -->"
93
+ assert start in text and end in text
94
+
95
+ block = text.split(start, 1)[1].split(end, 1)[0]
96
+ documented = {line.strip() for line in block.splitlines() if line.strip()}
97
+ assert documented == TOOL_SERVICE_ERROR_CODES
98
 
99
 
100
  def test_telemetry_snapshot_averages():
 
120
  assert snapshot["avg_tts_first_audio_ms"] == 250.0
121
  assert snapshot["service_errors"] == 3.0
122
  assert snapshot["storage_errors"] == 1.0
123
+ assert snapshot["unknown_summary_details"] == 0.0
124
  assert snapshot["fallback_responses"] == 4.0
125
 
126
 
 
138
 
139
  assert jarvis._telemetry["service_errors"] == 1.0
140
  assert jarvis._telemetry["storage_errors"] == 1.0
141
+ assert jarvis._telemetry["unknown_summary_details"] == 0.0
142
 
143
 
144
  def test_refresh_tool_error_counters_classifies_missing_store_as_storage():
 
155
 
156
  assert jarvis._telemetry["service_errors"] == 1.0
157
  assert jarvis._telemetry["storage_errors"] == 2.0
158
+ assert jarvis._telemetry["unknown_summary_details"] == 0.0
159
 
160
 
161
  def test_refresh_tool_error_counters_includes_network_taxonomy():
 
172
 
173
  assert jarvis._telemetry["service_errors"] == 3.0
174
  assert jarvis._telemetry["storage_errors"] == 0.0
175
+ assert jarvis._telemetry["unknown_summary_details"] == 0.0
176
+
177
+
178
+ def test_refresh_tool_error_counters_tracks_unknown_summary_detail_count():
179
+ jarvis = Jarvis.__new__(Jarvis)
180
+ jarvis._telemetry = {"service_errors": 0.0, "storage_errors": 0.0, "unknown_summary_details": 0.0}
181
+ from unittest.mock import patch
182
+
183
+ with patch("jarvis.__main__.list_summaries", return_value=[
184
+ {"status": "error", "detail": "timeout"},
185
+ {"status": "error", "detail": "not_a_real_code"},
186
+ {"status": "error", "detail": "another_unknown"},
187
+ ]):
188
+ Jarvis._refresh_tool_error_counters(jarvis)
189
+
190
+ assert jarvis._telemetry["service_errors"] == 1.0
191
+ assert jarvis._telemetry["storage_errors"] == 0.0
192
+ assert jarvis._telemetry["unknown_summary_details"] == 2.0
193
+
194
+
195
+ def test_refresh_tool_error_counters_reports_per_code_totals():
196
+ jarvis = Jarvis.__new__(Jarvis)
197
+ jarvis._telemetry = {"service_errors": 0.0, "storage_errors": 0.0, "unknown_summary_details": 0.0}
198
+ from unittest.mock import patch
199
+
200
+ with patch("jarvis.__main__.list_summaries", return_value=[
201
+ {"status": "error", "detail": "timeout"},
202
+ {"status": "error", "detail": "timeout"},
203
+ {"status": "error", "detail": "storage_error"},
204
+ {"status": "error", "detail": "unknown_error"},
205
+ {"status": "error", "detail": "not_a_real_code"},
206
+ {"status": "ok", "detail": "timeout"},
207
+ ]):
208
+ Jarvis._refresh_tool_error_counters(jarvis)
209
+
210
+ assert jarvis._telemetry_error_counts == {
211
+ "storage_error": 1.0,
212
+ "timeout": 2.0,
213
+ "unknown_error": 1.0,
214
+ }
215
+
216
+ snapshot = Jarvis._telemetry_snapshot(jarvis)
217
+ assert snapshot["service_error_counts"] == jarvis._telemetry_error_counts
218
+ assert snapshot["unknown_summary_details"] == 1.0
219
+
220
+
221
+ def test_telemetry_snapshot_guards_non_finite_values():
222
+ jarvis = Jarvis.__new__(Jarvis)
223
+ jarvis._telemetry = {
224
+ "turns": float("nan"),
225
+ "barge_ins": float("inf"),
226
+ "stt_latency_total_ms": float("inf"),
227
+ "stt_latency_count": 1.0,
228
+ "llm_first_sentence_total_ms": 100.0,
229
+ "llm_first_sentence_count": float("nan"),
230
+ "tts_first_audio_total_ms": 100.0,
231
+ "tts_first_audio_count": 0.0,
232
+ "service_errors": float("nan"),
233
+ "storage_errors": float("inf"),
234
+ "unknown_summary_details": float("nan"),
235
+ "fallback_responses": float("inf"),
236
+ }
237
+ jarvis._telemetry_error_counts = {"timeout": float("nan"), "auth": 2.0}
238
+
239
+ snapshot = Jarvis._telemetry_snapshot(jarvis)
240
+ assert snapshot["turns"] == 0.0
241
+ assert snapshot["barge_ins"] == 0.0
242
+ assert snapshot["avg_stt_latency_ms"] == 0.0
243
+ assert snapshot["avg_llm_first_sentence_ms"] == 0.0
244
+ assert snapshot["service_errors"] == 0.0
245
+ assert snapshot["storage_errors"] == 0.0
246
+ assert snapshot["unknown_summary_details"] == 0.0
247
+ assert snapshot["fallback_responses"] == 0.0
248
+ assert snapshot["service_error_counts"] == {"auth": 2.0}
249
 
250
 
251
  def test_telemetry_error_taxonomy_matches_service_error_codes():