nat-engine 1__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.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- nat_engine-1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# Copyright (C) 2026 Brad Guider
|
|
2
|
+
# This file is part of NAT (Neural Agent Testing Framework).
|
|
3
|
+
# Licensed under the AGPL-3.0. See LICENSE for details.
|
|
4
|
+
# Commercial licensing available — see COMMERCIAL_LICENSE.md.
|
|
5
|
+
|
|
6
|
+
"""LLM-powered root cause analysis for autonomous loop failures (Phase 6.5).
|
|
7
|
+
|
|
8
|
+
Calls the configured LLM provider with :data:`ROOT_CAUSE_ANALYSIS_PROMPT`
|
|
9
|
+
(defined in :mod:`mannf.product.llm.prompts`) to suggest likely root causes
|
|
10
|
+
for new failures.
|
|
11
|
+
|
|
12
|
+
Output schema (JSON returned by LLM, validated here)::
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
"likely_cause": "...",
|
|
16
|
+
"suggested_fix": "...",
|
|
17
|
+
"confidence": 0.8,
|
|
18
|
+
"files_to_check": ["path/to/file.py", ...]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
Missing or invalid fields are filled with safe defaults so the caller always
|
|
22
|
+
gets a usable dict.
|
|
23
|
+
|
|
24
|
+
The analyzer is gated by:
|
|
25
|
+
- LLM provider availability (returns ``None`` immediately when not configured)
|
|
26
|
+
- Severity threshold (only called for failures whose ``fault_likelihood``
|
|
27
|
+
exceeds *min_fault_likelihood*)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
from typing import Any, Dict, List, Optional
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
# Minimum fault_likelihood to trigger root cause analysis (avoids burning LLM
|
|
39
|
+
# tokens on low-probability noise).
|
|
40
|
+
_DEFAULT_MIN_FAULT_LIKELIHOOD = 0.6
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RootCauseResult:
|
|
44
|
+
"""Structured result from the LLM root cause analysis call.
|
|
45
|
+
|
|
46
|
+
Attributes
|
|
47
|
+
----------
|
|
48
|
+
likely_cause : str
|
|
49
|
+
suggested_fix : str
|
|
50
|
+
confidence : float
|
|
51
|
+
files_to_check : list[str]
|
|
52
|
+
raw_response : str
|
|
53
|
+
The raw string returned by the LLM (for debugging / auditing).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
likely_cause: str,
|
|
59
|
+
suggested_fix: str,
|
|
60
|
+
confidence: float,
|
|
61
|
+
files_to_check: List[str],
|
|
62
|
+
raw_response: str = "",
|
|
63
|
+
) -> None:
|
|
64
|
+
self.likely_cause = likely_cause
|
|
65
|
+
self.suggested_fix = suggested_fix
|
|
66
|
+
self.confidence = max(0.0, min(1.0, float(confidence)))
|
|
67
|
+
self.files_to_check = files_to_check
|
|
68
|
+
self.raw_response = raw_response
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
71
|
+
return {
|
|
72
|
+
"likely_cause": self.likely_cause,
|
|
73
|
+
"suggested_fix": self.suggested_fix,
|
|
74
|
+
"confidence": self.confidence,
|
|
75
|
+
"files_to_check": self.files_to_check,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _parse_llm_response(raw: str) -> Dict[str, Any]:
|
|
80
|
+
"""Extract the JSON object from the LLM response string."""
|
|
81
|
+
# Strip markdown fences if present
|
|
82
|
+
text = raw.strip()
|
|
83
|
+
if text.startswith("```"):
|
|
84
|
+
lines = text.splitlines()
|
|
85
|
+
# Drop first line (```json or ```) and last line (```)
|
|
86
|
+
inner = [l for l in lines[1:] if l.strip() != "```"]
|
|
87
|
+
text = "\n".join(inner).strip()
|
|
88
|
+
return json.loads(text)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class RootCauseAnalyzer:
|
|
92
|
+
"""Analyse failures with an LLM to suggest root causes.
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
llm_provider : object or None
|
|
97
|
+
An initialised LLM provider instance that exposes an
|
|
98
|
+
``acomplete(prompt: str) -> str`` (async) or
|
|
99
|
+
``complete(prompt: str) -> str`` (sync) coroutine / method.
|
|
100
|
+
When ``None``, :meth:`analyze` returns ``None`` immediately.
|
|
101
|
+
min_fault_likelihood : float
|
|
102
|
+
Minimum fault likelihood required to trigger analysis.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
llm_provider: Optional[Any] = None,
|
|
108
|
+
min_fault_likelihood: float = _DEFAULT_MIN_FAULT_LIKELIHOOD,
|
|
109
|
+
) -> None:
|
|
110
|
+
self._provider = llm_provider
|
|
111
|
+
self._min_fault_likelihood = min_fault_likelihood
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
# Public API
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
async def analyze(
|
|
118
|
+
self,
|
|
119
|
+
failure: Dict[str, Any],
|
|
120
|
+
fault_likelihood: float = 1.0,
|
|
121
|
+
belief_history: Optional[List[Dict[str, Any]]] = None,
|
|
122
|
+
dom_snapshot: str = "",
|
|
123
|
+
scenario_steps: Optional[List[str]] = None,
|
|
124
|
+
page_metadata: Optional[Dict[str, Any]] = None,
|
|
125
|
+
) -> Optional[RootCauseResult]:
|
|
126
|
+
"""Analyse a single failure and return a :class:`RootCauseResult`.
|
|
127
|
+
|
|
128
|
+
Returns ``None`` when LLM is unavailable or fault_likelihood is below
|
|
129
|
+
threshold.
|
|
130
|
+
|
|
131
|
+
Parameters
|
|
132
|
+
----------
|
|
133
|
+
failure:
|
|
134
|
+
Failure dict with at least ``url``, ``error``, ``task_id`` keys.
|
|
135
|
+
fault_likelihood:
|
|
136
|
+
Current belief state fault_likelihood for the page (0–1).
|
|
137
|
+
belief_history:
|
|
138
|
+
Optional list of prior belief snapshots for this page, each a
|
|
139
|
+
dict with ``iteration``, ``fault_likelihood``, ``timestamp``.
|
|
140
|
+
dom_snapshot:
|
|
141
|
+
Optional DOM/HTML snapshot string for context.
|
|
142
|
+
scenario_steps:
|
|
143
|
+
Optional list of step descriptions from the failing scenario.
|
|
144
|
+
page_metadata:
|
|
145
|
+
Optional metadata dict about the page.
|
|
146
|
+
"""
|
|
147
|
+
if self._provider is None:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
if fault_likelihood < self._min_fault_likelihood:
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
prompt = self._build_prompt(
|
|
154
|
+
failure=failure,
|
|
155
|
+
fault_likelihood=fault_likelihood,
|
|
156
|
+
belief_history=belief_history or [],
|
|
157
|
+
dom_snapshot=dom_snapshot,
|
|
158
|
+
scenario_steps=scenario_steps or [],
|
|
159
|
+
page_metadata=page_metadata or {},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
raw = await self._call_llm(prompt)
|
|
164
|
+
return self._parse_result(raw)
|
|
165
|
+
except Exception as exc: # noqa: BLE001
|
|
166
|
+
logger.warning("RootCauseAnalyzer: LLM call failed — %s", exc)
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def analyze_sync(
|
|
170
|
+
self,
|
|
171
|
+
failure: Dict[str, Any],
|
|
172
|
+
fault_likelihood: float = 1.0,
|
|
173
|
+
belief_history: Optional[List[Dict[str, Any]]] = None,
|
|
174
|
+
dom_snapshot: str = "",
|
|
175
|
+
scenario_steps: Optional[List[str]] = None,
|
|
176
|
+
page_metadata: Optional[Dict[str, Any]] = None,
|
|
177
|
+
) -> Optional[RootCauseResult]:
|
|
178
|
+
"""Synchronous variant of :meth:`analyze`."""
|
|
179
|
+
if self._provider is None:
|
|
180
|
+
return None
|
|
181
|
+
if fault_likelihood < self._min_fault_likelihood:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
prompt = self._build_prompt(
|
|
185
|
+
failure=failure,
|
|
186
|
+
fault_likelihood=fault_likelihood,
|
|
187
|
+
belief_history=belief_history or [],
|
|
188
|
+
dom_snapshot=dom_snapshot,
|
|
189
|
+
scenario_steps=scenario_steps or [],
|
|
190
|
+
page_metadata=page_metadata or {},
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
raw = self._call_llm_sync(prompt)
|
|
195
|
+
return self._parse_result(raw)
|
|
196
|
+
except Exception as exc: # noqa: BLE001
|
|
197
|
+
logger.warning("RootCauseAnalyzer: sync LLM call failed — %s", exc)
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
# Internals
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
def _build_prompt(
|
|
205
|
+
self,
|
|
206
|
+
failure: Dict[str, Any],
|
|
207
|
+
fault_likelihood: float,
|
|
208
|
+
belief_history: List[Dict[str, Any]],
|
|
209
|
+
dom_snapshot: str,
|
|
210
|
+
scenario_steps: List[str],
|
|
211
|
+
page_metadata: Dict[str, Any],
|
|
212
|
+
) -> str:
|
|
213
|
+
from mannf.product.llm.prompts import ROOT_CAUSE_ANALYSIS_PROMPT # noqa: PLC0415
|
|
214
|
+
|
|
215
|
+
belief_history_str = (
|
|
216
|
+
json.dumps(belief_history, indent=2) if belief_history else "No prior history available"
|
|
217
|
+
)
|
|
218
|
+
scenario_steps_str = (
|
|
219
|
+
"\n".join(f" {i + 1}. {s}" for i, s in enumerate(scenario_steps))
|
|
220
|
+
if scenario_steps
|
|
221
|
+
else " (no steps recorded)"
|
|
222
|
+
)
|
|
223
|
+
dom_snippet = (dom_snapshot or "")[:2000] # cap DOM context length
|
|
224
|
+
page_meta_str = json.dumps(page_metadata) if page_metadata else "{}"
|
|
225
|
+
|
|
226
|
+
return ROOT_CAUSE_ANALYSIS_PROMPT.format(
|
|
227
|
+
url=failure.get("url", ""),
|
|
228
|
+
page=failure.get("page", ""),
|
|
229
|
+
error=failure.get("error", ""),
|
|
230
|
+
task_id=failure.get("task_id", ""),
|
|
231
|
+
fault_likelihood=round(fault_likelihood, 3),
|
|
232
|
+
belief_history=belief_history_str,
|
|
233
|
+
dom_snapshot=dom_snippet,
|
|
234
|
+
scenario_steps=scenario_steps_str,
|
|
235
|
+
page_metadata=page_meta_str,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
async def _call_llm(self, prompt: str) -> str:
|
|
239
|
+
"""Invoke the LLM provider (async)."""
|
|
240
|
+
provider = self._provider
|
|
241
|
+
if hasattr(provider, "acomplete"):
|
|
242
|
+
return await provider.acomplete(prompt)
|
|
243
|
+
if hasattr(provider, "complete"):
|
|
244
|
+
result = provider.complete(prompt)
|
|
245
|
+
# Handle both sync and coroutine returns
|
|
246
|
+
if hasattr(result, "__await__"):
|
|
247
|
+
return await result
|
|
248
|
+
return result
|
|
249
|
+
raise AttributeError("LLM provider has no 'acomplete' or 'complete' method")
|
|
250
|
+
|
|
251
|
+
def _call_llm_sync(self, prompt: str) -> str:
|
|
252
|
+
"""Invoke the LLM provider (sync)."""
|
|
253
|
+
provider = self._provider
|
|
254
|
+
if hasattr(provider, "complete"):
|
|
255
|
+
return provider.complete(prompt)
|
|
256
|
+
raise AttributeError("LLM provider has no 'complete' method for sync use")
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def _parse_result(raw: str) -> RootCauseResult:
|
|
260
|
+
"""Parse the LLM JSON response into a :class:`RootCauseResult`."""
|
|
261
|
+
try:
|
|
262
|
+
data = _parse_llm_response(raw)
|
|
263
|
+
except (json.JSONDecodeError, ValueError):
|
|
264
|
+
logger.warning("RootCauseAnalyzer: could not parse LLM response as JSON")
|
|
265
|
+
data = {}
|
|
266
|
+
|
|
267
|
+
return RootCauseResult(
|
|
268
|
+
likely_cause=str(data.get("likely_cause", "Unable to determine root cause")),
|
|
269
|
+
suggested_fix=str(data.get("suggested_fix", "Investigate the error manually")),
|
|
270
|
+
confidence=float(data.get("confidence", 0.0)),
|
|
271
|
+
files_to_check=list(data.get("files_to_check", [])),
|
|
272
|
+
raw_response=raw,
|
|
273
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Copyright (C) 2026 Brad Guider
|
|
2
|
+
# This file is part of NAT (Neural Agent Testing Framework).
|
|
3
|
+
# Licensed under the AGPL-3.0. See LICENSE for details.
|
|
4
|
+
# Commercial licensing available — see COMMERCIAL_LICENSE.md.
|
|
5
|
+
|
|
6
|
+
"""Distributed sub-package for the Multi-Agent Neural Network Framework."""
|
|
7
|
+
|
|
8
|
+
from mannf.core.distributed.endpoint import Endpoint, EndpointRegistry
|
|
9
|
+
from mannf.core.distributed.system_under_test import SystemUnderTest, MockDistributedSystem
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Endpoint",
|
|
13
|
+
"EndpointRegistry",
|
|
14
|
+
"SystemUnderTest",
|
|
15
|
+
"MockDistributedSystem",
|
|
16
|
+
]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Copyright (C) 2026 Brad Guider
|
|
2
|
+
# This file is part of NAT (Neural Agent Testing Framework).
|
|
3
|
+
# Licensed under the AGPL-3.0. See LICENSE for details.
|
|
4
|
+
# Commercial licensing available — see COMMERCIAL_LICENSE.md.
|
|
5
|
+
|
|
6
|
+
"""Distributed endpoint model.
|
|
7
|
+
|
|
8
|
+
An :class:`Endpoint` represents one service/component in the system under test.
|
|
9
|
+
It stores connection metadata and exposes a uniform *invoke* interface.
|
|
10
|
+
|
|
11
|
+
Supported protocol values include:
|
|
12
|
+
|
|
13
|
+
* ``"http"`` / ``"https"`` — REST HTTP APIs
|
|
14
|
+
* ``"grpc"`` — gRPC services (plaintext)
|
|
15
|
+
* ``"grpcs"`` — gRPC services (TLS)
|
|
16
|
+
* ``"ws"`` — WebSocket connections (plaintext)
|
|
17
|
+
* ``"wss"`` — WebSocket connections (TLS)
|
|
18
|
+
* ``"kafka"`` — Kafka broker
|
|
19
|
+
* ``"tcp"`` — generic TCP
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
26
|
+
|
|
27
|
+
# Protocol families for quick categorisation
|
|
28
|
+
_HTTP_PROTOCOLS = frozenset({"http", "https"})
|
|
29
|
+
_GRPC_PROTOCOLS = frozenset({"grpc", "grpcs"})
|
|
30
|
+
_WS_PROTOCOLS = frozenset({"ws", "wss", "websocket", "websockets"})
|
|
31
|
+
_ASYNC_PROTOCOLS = frozenset({"kafka", "amqp", "amqps", "mqtt"})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Endpoint:
|
|
36
|
+
"""Metadata about one distributed service endpoint.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
name:
|
|
41
|
+
Logical name of the service (e.g. ``"auth-service"``).
|
|
42
|
+
host:
|
|
43
|
+
Hostname or IP address.
|
|
44
|
+
port:
|
|
45
|
+
TCP port.
|
|
46
|
+
protocol:
|
|
47
|
+
Application protocol — one of ``"http"``, ``"https"``, ``"grpc"``,
|
|
48
|
+
``"grpcs"``, ``"ws"``, ``"wss"``, ``"kafka"``, ``"tcp"``.
|
|
49
|
+
health_check_path:
|
|
50
|
+
Path used to verify the service is alive (HTTP/WebSocket only).
|
|
51
|
+
tags:
|
|
52
|
+
Arbitrary labels for filtering/grouping.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
host: str
|
|
57
|
+
port: int
|
|
58
|
+
protocol: str = "http"
|
|
59
|
+
health_check_path: str = "/health"
|
|
60
|
+
tags: Dict[str, str] = field(default_factory=dict)
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
# Protocol family helpers
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def is_http(self) -> bool:
|
|
68
|
+
"""Return True for HTTP/HTTPS endpoints."""
|
|
69
|
+
return self.protocol in _HTTP_PROTOCOLS
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def is_grpc(self) -> bool:
|
|
73
|
+
"""Return True for gRPC endpoints (plain or TLS)."""
|
|
74
|
+
return self.protocol in _GRPC_PROTOCOLS
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def is_websocket(self) -> bool:
|
|
78
|
+
"""Return True for WebSocket endpoints (plain or TLS)."""
|
|
79
|
+
return self.protocol in _WS_PROTOCOLS
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def is_async(self) -> bool:
|
|
83
|
+
"""Return True for async/event-driven endpoints (Kafka, AMQP, MQTT)."""
|
|
84
|
+
return self.protocol in _ASYNC_PROTOCOLS
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
# URL builders
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def base_url(self) -> str:
|
|
92
|
+
if self.protocol == "http":
|
|
93
|
+
return f"http://{self.host}:{self.port}"
|
|
94
|
+
if self.protocol == "https":
|
|
95
|
+
return f"https://{self.host}:{self.port}"
|
|
96
|
+
if self.protocol in ("grpc", "grpcs"):
|
|
97
|
+
return f"{self.host}:{self.port}"
|
|
98
|
+
if self.protocol == "ws":
|
|
99
|
+
return f"ws://{self.host}:{self.port}"
|
|
100
|
+
if self.protocol in ("wss", "websocket", "websockets"):
|
|
101
|
+
return f"wss://{self.host}:{self.port}"
|
|
102
|
+
return f"{self.protocol}://{self.host}:{self.port}"
|
|
103
|
+
|
|
104
|
+
def __str__(self) -> str:
|
|
105
|
+
return f"{self.name}@{self.base_url}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class EndpointRegistry:
|
|
110
|
+
"""Holds all known endpoints for the system under test."""
|
|
111
|
+
|
|
112
|
+
_endpoints: Dict[str, Endpoint] = field(default_factory=dict)
|
|
113
|
+
|
|
114
|
+
def register(self, endpoint: Endpoint) -> None:
|
|
115
|
+
self._endpoints[endpoint.name] = endpoint
|
|
116
|
+
|
|
117
|
+
def get(self, name: str) -> Optional[Endpoint]:
|
|
118
|
+
return self._endpoints.get(name)
|
|
119
|
+
|
|
120
|
+
def all(self) -> list[Endpoint]:
|
|
121
|
+
return list(self._endpoints.values())
|
|
122
|
+
|
|
123
|
+
def by_protocol(self, protocol: str) -> list[Endpoint]:
|
|
124
|
+
"""Return all endpoints with the given *protocol*."""
|
|
125
|
+
return [e for e in self._endpoints.values() if e.protocol == protocol]
|
|
126
|
+
|
|
127
|
+
def grpc_endpoints(self) -> list[Endpoint]:
|
|
128
|
+
"""Return all gRPC endpoints."""
|
|
129
|
+
return [e for e in self._endpoints.values() if e.is_grpc]
|
|
130
|
+
|
|
131
|
+
def websocket_endpoints(self) -> list[Endpoint]:
|
|
132
|
+
"""Return all WebSocket endpoints."""
|
|
133
|
+
return [e for e in self._endpoints.values() if e.is_websocket]
|
|
134
|
+
|
|
135
|
+
def by_tag(self, key: str, value: str) -> list[Endpoint]:
|
|
136
|
+
return [e for e in self._endpoints.values() if e.tags.get(key) == value]
|
|
137
|
+
|
|
138
|
+
def __len__(self) -> int:
|
|
139
|
+
return len(self._endpoints)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Copyright (C) 2026 Brad Guider
|
|
2
|
+
# This file is part of NAT (Neural Agent Testing Framework).
|
|
3
|
+
# Licensed under the AGPL-3.0. See LICENSE for details.
|
|
4
|
+
# Commercial licensing available — see COMMERCIAL_LICENSE.md.
|
|
5
|
+
|
|
6
|
+
"""System-Under-Test (SUT) abstraction.
|
|
7
|
+
|
|
8
|
+
:class:`SystemUnderTest` is the interface the agents use to interact with the
|
|
9
|
+
distributed system being tested. The concrete class provided here is
|
|
10
|
+
:class:`MockDistributedSystem`, a fully in-process simulation of a small
|
|
11
|
+
microservice landscape that is ready to use without any network infrastructure.
|
|
12
|
+
|
|
13
|
+
The mock deliberately injects configurable faults so the framework can
|
|
14
|
+
demonstrate its ability to find them adaptively.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import logging
|
|
21
|
+
import random
|
|
22
|
+
import time
|
|
23
|
+
from abc import ABC, abstractmethod
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any, Dict, List, Optional
|
|
26
|
+
|
|
27
|
+
from mannf.core.distributed.endpoint import Endpoint, EndpointRegistry
|
|
28
|
+
from mannf.core.testing.models import TestCase, TestResult
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Abstract interface
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
class SystemUnderTest(ABC):
|
|
38
|
+
"""Abstract base class for any system the framework will test."""
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
async def execute(self, test_case: TestCase) -> TestResult:
|
|
42
|
+
"""Execute *test_case* and return its result."""
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def registry(self) -> EndpointRegistry:
|
|
47
|
+
"""Return the endpoint registry for this system."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Mock distributed system
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class _ServiceConfig:
|
|
56
|
+
"""Configuration for one simulated microservice."""
|
|
57
|
+
|
|
58
|
+
name: str
|
|
59
|
+
failure_rate: float = 0.0 # probability of returning a failure response
|
|
60
|
+
error_rate: float = 0.0 # probability of raising an exception
|
|
61
|
+
latency_ms: float = 10.0 # base latency
|
|
62
|
+
latency_jitter_ms: float = 5.0 # latency jitter (uniform)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MockDistributedSystem(SystemUnderTest):
|
|
66
|
+
"""An in-process simulation of a multi-service distributed system.
|
|
67
|
+
|
|
68
|
+
Services can be added with configurable failure/error rates so that the
|
|
69
|
+
adaptive testing agents can demonstrate their ability to converge on
|
|
70
|
+
high-failure areas.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
seed:
|
|
75
|
+
Optional random seed for reproducible simulations.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
# Default service topology
|
|
79
|
+
_DEFAULT_SERVICES: List[_ServiceConfig] = [
|
|
80
|
+
_ServiceConfig("api-gateway", failure_rate=0.05, error_rate=0.01, latency_ms=15),
|
|
81
|
+
_ServiceConfig("auth-service", failure_rate=0.10, error_rate=0.02, latency_ms=20),
|
|
82
|
+
_ServiceConfig("user-service", failure_rate=0.03, error_rate=0.01, latency_ms=10),
|
|
83
|
+
_ServiceConfig("order-service", failure_rate=0.15, error_rate=0.03, latency_ms=25),
|
|
84
|
+
_ServiceConfig("payment-service", failure_rate=0.20, error_rate=0.05, latency_ms=30),
|
|
85
|
+
_ServiceConfig("inventory-service",failure_rate=0.08, error_rate=0.02, latency_ms=12),
|
|
86
|
+
_ServiceConfig("notification-svc", failure_rate=0.04, error_rate=0.01, latency_ms=8),
|
|
87
|
+
_ServiceConfig("analytics-service",failure_rate=0.06, error_rate=0.01, latency_ms=50),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
services: Optional[List[_ServiceConfig]] = None,
|
|
93
|
+
seed: Optional[int] = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
if seed is not None:
|
|
96
|
+
random.seed(seed)
|
|
97
|
+
|
|
98
|
+
self._services: Dict[str, _ServiceConfig] = {}
|
|
99
|
+
self._registry = EndpointRegistry()
|
|
100
|
+
|
|
101
|
+
for svc in services or self._DEFAULT_SERVICES:
|
|
102
|
+
self._services[svc.name] = svc
|
|
103
|
+
self._registry.register(
|
|
104
|
+
Endpoint(
|
|
105
|
+
name=svc.name,
|
|
106
|
+
host="localhost",
|
|
107
|
+
port=8000 + len(self._services),
|
|
108
|
+
protocol="http",
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
self._call_log: List[Dict[str, Any]] = []
|
|
113
|
+
|
|
114
|
+
# -- SystemUnderTest interface ----------------------------------------
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def registry(self) -> EndpointRegistry:
|
|
118
|
+
return self._registry
|
|
119
|
+
|
|
120
|
+
async def execute(self, test_case: TestCase) -> TestResult:
|
|
121
|
+
"""Simulate executing *test_case* against the target service."""
|
|
122
|
+
start = time.monotonic()
|
|
123
|
+
|
|
124
|
+
service = self._services.get(test_case.target)
|
|
125
|
+
if service is None:
|
|
126
|
+
return TestResult(
|
|
127
|
+
test_case_id=test_case.id,
|
|
128
|
+
passed=False,
|
|
129
|
+
error=f"Unknown service: {test_case.target}",
|
|
130
|
+
execution_time_ms=(time.monotonic() - start) * 1000,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Simulate network latency
|
|
134
|
+
jitter = random.uniform(0, service.latency_jitter_ms)
|
|
135
|
+
await asyncio.sleep((service.latency_ms + jitter) / 1000.0)
|
|
136
|
+
|
|
137
|
+
execution_time_ms = (time.monotonic() - start) * 1000
|
|
138
|
+
|
|
139
|
+
# Simulate errors (exceptions)
|
|
140
|
+
if random.random() < service.error_rate:
|
|
141
|
+
error_msg = f"Simulated error in {service.name}: connection timeout"
|
|
142
|
+
self._log(test_case, passed=False, error=error_msg)
|
|
143
|
+
return TestResult(
|
|
144
|
+
test_case_id=test_case.id,
|
|
145
|
+
passed=False,
|
|
146
|
+
error=error_msg,
|
|
147
|
+
execution_time_ms=execution_time_ms,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Simulate functional failures
|
|
151
|
+
passed = random.random() >= service.failure_rate
|
|
152
|
+
|
|
153
|
+
output = {
|
|
154
|
+
"service": service.name,
|
|
155
|
+
"status": "ok" if passed else "error",
|
|
156
|
+
"inputs_received": test_case.inputs,
|
|
157
|
+
}
|
|
158
|
+
self._log(test_case, passed=passed)
|
|
159
|
+
return TestResult(
|
|
160
|
+
test_case_id=test_case.id,
|
|
161
|
+
passed=passed,
|
|
162
|
+
actual_output=output,
|
|
163
|
+
execution_time_ms=execution_time_ms,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# -- Introspection helpers --------------------------------------------
|
|
167
|
+
|
|
168
|
+
def _log(
|
|
169
|
+
self,
|
|
170
|
+
test_case: TestCase,
|
|
171
|
+
passed: bool,
|
|
172
|
+
error: Optional[str] = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
self._call_log.append(
|
|
175
|
+
{
|
|
176
|
+
"target": test_case.target,
|
|
177
|
+
"passed": passed,
|
|
178
|
+
"error": error,
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def call_log(self) -> List[Dict[str, Any]]:
|
|
183
|
+
"""Return a copy of the call log (for testing / observability)."""
|
|
184
|
+
return list(self._call_log)
|
|
185
|
+
|
|
186
|
+
def failure_counts(self) -> Dict[str, int]:
|
|
187
|
+
"""Return per-service failure+error counts."""
|
|
188
|
+
counts: Dict[str, int] = {}
|
|
189
|
+
for entry in self._call_log:
|
|
190
|
+
if not entry["passed"] or entry["error"]:
|
|
191
|
+
counts[entry["target"]] = counts.get(entry["target"], 0) + 1
|
|
192
|
+
return counts
|
|
193
|
+
|
|
194
|
+
def service_names(self) -> List[str]:
|
|
195
|
+
return list(self._services.keys())
|
|
196
|
+
|
|
197
|
+
def add_service(self, config: _ServiceConfig) -> None:
|
|
198
|
+
"""Dynamically register an additional simulated service."""
|
|
199
|
+
self._services[config.name] = config
|
|
200
|
+
self._registry.register(
|
|
201
|
+
Endpoint(
|
|
202
|
+
name=config.name,
|
|
203
|
+
host="localhost",
|
|
204
|
+
port=8000 + len(self._services),
|
|
205
|
+
protocol="http",
|
|
206
|
+
)
|
|
207
|
+
)
|