spot-sdk-python 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.
spot_sdk/__init__.py ADDED
@@ -0,0 +1,86 @@
1
+ """SPOT API interfaces and data models."""
2
+
3
+ from .analysis_context import AnalysisContextReader
4
+ from .analyzer import AnalyzerCapability, AnalyzerInterface, ThreatLevel
5
+ from .config import ConfigOption, ConfigReloadResult, ConfigStatus, ConfigUpdateRequest
6
+ from .config_helpers import merge_settings
7
+ from .email import Attachment, Email, EmailHeader
8
+ from .errors import ErrorResponse
9
+ from .knowledge import KnowledgeClient, KnowledgeDocument, chunk_text, content_hash
10
+ from .knowledge_tags import KnowledgeTag
11
+ from .logging import configure_logging, get_logger, get_logging_config
12
+ from .ollama import LLMResponse, OllamaClientProtocol
13
+ from .orchestrator import AnalyzerResult, OrchestrationResult, WorkflowStageResult
14
+ from .plugin import PluginKind
15
+ from .results import (
16
+ AnalysisIndicator,
17
+ AnalysisMetadata,
18
+ AnalysisResult,
19
+ IndicatorType,
20
+ )
21
+ from .settings_schema import register_settings_schema
22
+ from .threat_levels import confidence_to_threat_level
23
+ from .workflow import (
24
+ AnalyzerConfig,
25
+ FailureStrategy,
26
+ RetrievalLimits,
27
+ RetryConfig,
28
+ Workflow,
29
+ WorkflowStage,
30
+ )
31
+
32
+ __all__ = [
33
+ # Analyzer interface
34
+ "AnalyzerInterface",
35
+ "AnalyzerCapability",
36
+ "ThreatLevel",
37
+ # Config models
38
+ "ConfigOption",
39
+ "ConfigStatus",
40
+ "ConfigUpdateRequest",
41
+ "ConfigReloadResult",
42
+ # Error response
43
+ "ErrorResponse",
44
+ # Email models
45
+ "Email",
46
+ "EmailHeader",
47
+ "Attachment",
48
+ "AnalysisContextReader",
49
+ # Analysis results
50
+ "AnalysisResult",
51
+ "AnalysisIndicator",
52
+ "AnalysisMetadata",
53
+ "IndicatorType",
54
+ # Logging
55
+ "configure_logging",
56
+ "get_logger",
57
+ "get_logging_config",
58
+ # Workflow
59
+ "Workflow",
60
+ "WorkflowStage",
61
+ "AnalyzerConfig",
62
+ "RetryConfig",
63
+ "RetrievalLimits",
64
+ "FailureStrategy",
65
+ # Knowledge Store
66
+ "KnowledgeDocument",
67
+ "KnowledgeClient",
68
+ "KnowledgeTag",
69
+ "chunk_text",
70
+ "content_hash",
71
+ # Plugin vocabulary
72
+ "PluginKind",
73
+ # Orchestration
74
+ "OrchestrationResult",
75
+ "AnalyzerResult",
76
+ "WorkflowStageResult",
77
+ # Ollama protocol
78
+ "OllamaClientProtocol",
79
+ "LLMResponse",
80
+ # Settings schema
81
+ "register_settings_schema",
82
+ # Config helpers
83
+ "merge_settings",
84
+ # Threat levels
85
+ "confidence_to_threat_level",
86
+ ]
@@ -0,0 +1,99 @@
1
+ """Ergonomic accessor for Email.analysis_context.
2
+
3
+ Wraps the stage-scoped dict produced by the orchestrator so analyzers can
4
+ look up previous stage results without writing manual dict traversal.
5
+
6
+ The underlying structure is:
7
+
8
+ {
9
+ "<stage-name>": {
10
+ "analyzers": {"<analyzer-id>": {...AnalyzerResult fields...}}
11
+ },
12
+ ...
13
+ }
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, cast
19
+
20
+
21
+ class AnalysisContextReader:
22
+ """Read-only accessor for Email.analysis_context data.
23
+
24
+ Use via the Email.ctx property:
25
+
26
+ nlp = email.ctx.analyzer("analyzer-nlp")
27
+ if nlp:
28
+ confidence = nlp["confidence"]
29
+
30
+ # All analyzers in a specific stage
31
+ for aid, data in email.ctx.analyzers_in("parallel-analysis").items():
32
+ ...
33
+
34
+ Missing keys return None (single accessors) or empty dict (bulk accessors).
35
+ Nothing in this class raises.
36
+ """
37
+
38
+ def __init__(self, context: dict[str, Any] | None) -> None:
39
+ self._ctx: dict[str, Any] = context or {}
40
+
41
+ # ------------------------------------------------------------------
42
+ # Stage listing
43
+ # ------------------------------------------------------------------
44
+ def stages(self) -> list[str]:
45
+ """Return the names of all stages that have completed, in insertion order."""
46
+ return list(self._ctx.keys())
47
+
48
+ def has_stage(self, stage: str) -> bool:
49
+ """Return True if the given stage has completed."""
50
+ return stage in self._ctx
51
+
52
+ # ------------------------------------------------------------------
53
+ # Analyzer accessors
54
+ # ------------------------------------------------------------------
55
+ def analyzer_ids(self) -> list[str]:
56
+ """Return all analyzer IDs across all stages (deduplicated, stable order)."""
57
+ seen: dict[str, None] = {}
58
+ for stage in self._ctx.values():
59
+ for aid in (stage.get("analyzers") or {}).keys():
60
+ seen[aid] = None
61
+ return list(seen.keys())
62
+
63
+ def analyzer_ids_in(self, stage: str) -> list[str]:
64
+ """Return analyzer IDs in a specific stage. Empty list if stage is absent."""
65
+ return list((self._ctx.get(stage, {}).get("analyzers") or {}).keys())
66
+
67
+ def analyzer(
68
+ self, analyzer_id: str, stage: str | None = None
69
+ ) -> dict[str, Any] | None:
70
+ """Return a single analyzer result, or None if not found.
71
+
72
+ If ``stage`` is given, only look in that stage.
73
+ If ``stage`` is None, return the first match across all stages (in stage order).
74
+ """
75
+ if stage is not None:
76
+ hit = (self._ctx.get(stage, {}).get("analyzers") or {}).get(analyzer_id)
77
+ return cast("dict[str, Any] | None", hit)
78
+ for stage_data in self._ctx.values():
79
+ analyzers = stage_data.get("analyzers") or {}
80
+ if analyzer_id in analyzers:
81
+ return cast("dict[str, Any]", analyzers[analyzer_id])
82
+ return None
83
+
84
+ def analyzers_in(self, stage: str) -> dict[str, dict[str, Any]]:
85
+ """Return all analyzer results in a stage as {analyzer_id: data}.
86
+
87
+ Empty dict if the stage is absent or has no analyzers.
88
+ """
89
+ return dict(self._ctx.get(stage, {}).get("analyzers") or {})
90
+
91
+ def all_analyzers(self) -> dict[str, dict[str, dict[str, Any]]]:
92
+ """Return all analyzer results across every stage.
93
+
94
+ Shape: ``{stage_name: {analyzer_id: data, ...}, ...}``
95
+ """
96
+ return {
97
+ stage_name: dict(stage_data.get("analyzers") or {})
98
+ for stage_name, stage_data in self._ctx.items()
99
+ }
spot_sdk/analyzer.py ADDED
@@ -0,0 +1,106 @@
1
+ """Analyzer interface definition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from enum import Enum
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from .email import Email
10
+
11
+ if TYPE_CHECKING:
12
+ from .results import AnalysisResult
13
+
14
+
15
+ class ThreatLevel(str, Enum):
16
+ """Email threat level classification."""
17
+
18
+ SAFE = "safe"
19
+ LOW = "low"
20
+ MEDIUM = "medium"
21
+ HIGH = "high"
22
+ CRITICAL = "critical"
23
+
24
+ def __str__(self) -> str:
25
+ return self.value
26
+
27
+
28
+ class AnalyzerCapability(str, Enum):
29
+ """Analyzer capability types."""
30
+
31
+ SENTIMENT_ANALYSIS = "sentiment_analysis"
32
+ ENTITY_RECOGNITION = "entity_recognition"
33
+ URL_ANALYSIS = "url_analysis"
34
+ ATTACHMENT_SCANNING = "attachment_scanning"
35
+ LANGUAGE_DETECTION = "language_detection"
36
+ PHISHING_PATTERNS = "phishing_patterns"
37
+ SOCIAL_ENGINEERING = "social_engineering"
38
+ DOMAIN_REPUTATION = "domain_reputation"
39
+
40
+ def __str__(self) -> str:
41
+ return self.value
42
+
43
+
44
+ class AnalyzerInterface(ABC):
45
+ """
46
+ Standard interface that all SPOT analyzers must implement.
47
+
48
+ This interface ensures compatibility across all analyzer plugins,
49
+ enabling the orchestrator to coordinate analysis workflows.
50
+ """
51
+
52
+ @abstractmethod
53
+ async def analyze(self, email: Email) -> AnalysisResult:
54
+ """
55
+ Analyze an email and return threat assessment.
56
+
57
+ Args:
58
+ email: Email object containing headers, body, and attachments
59
+
60
+ Returns:
61
+ AnalysisResult: Structured analysis result with threat assessment
62
+
63
+ Raises:
64
+ AnalysisError: If analysis cannot be completed
65
+ """
66
+ pass
67
+
68
+ @abstractmethod
69
+ async def get_capabilities(self) -> list[AnalyzerCapability]:
70
+ """
71
+ Return list of analysis capabilities this analyzer provides.
72
+
73
+ Returns:
74
+ List of AnalyzerCapability enums
75
+ """
76
+ pass
77
+
78
+ @abstractmethod
79
+ async def health_check(self) -> bool:
80
+ """
81
+ Check if analyzer is healthy and ready to process emails.
82
+
83
+ Returns:
84
+ True if healthy, False otherwise
85
+ """
86
+ pass
87
+
88
+ @abstractmethod
89
+ def get_version(self) -> str:
90
+ """
91
+ Return analyzer version string.
92
+
93
+ Returns:
94
+ Semantic version string (e.g., "1.2.3")
95
+ """
96
+ pass
97
+
98
+ @abstractmethod
99
+ async def get_configuration(self) -> dict[str, Any]:
100
+ """
101
+ Return current analyzer configuration.
102
+
103
+ Returns:
104
+ Dictionary containing analyzer configuration parameters
105
+ """
106
+ pass
@@ -0,0 +1,316 @@
1
+ """Base classes for SPOT analyzer implementations.
2
+
3
+ Provides:
4
+ - BaseAnalyzerSettings: Common settings fields all analyzers share
5
+ - settings_singleton: Decorator to add caching to a get_settings() function
6
+ - merge_settings: Merge base settings with central config overrides
7
+ - create_analyzer_app: Factory for FastAPI app with standard analyzer endpoints
8
+
9
+ Usage in an analyzer's config.py::
10
+
11
+ from spot_sdk.analyzer_base import BaseAnalyzerSettings, settings_singleton
12
+
13
+ class Settings(BaseAnalyzerSettings):
14
+ app_name: str = "analyzer-nlp"
15
+ model_path: str = "/models/distilbert"
16
+ use_gpu: bool = False
17
+
18
+ @settings_singleton
19
+ def get_settings() -> Settings:
20
+ return Settings()
21
+
22
+ Usage in an analyzer's main.py::
23
+
24
+ from spot_sdk.analyzer_base import create_analyzer_app
25
+
26
+ app, state = create_analyzer_app(
27
+ title="SPOT NLP Analyzer",
28
+ description="NLP-based phishing email analyzer",
29
+ analyzer_id="analyzer-nlp",
30
+ settings_factory=get_settings,
31
+ )
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import os
37
+ from collections.abc import Awaitable, Callable
38
+ from functools import wraps
39
+ from typing import Any, TypeVar
40
+
41
+ import uvicorn
42
+ from fastapi import FastAPI
43
+ from pydantic_settings import BaseSettings, SettingsConfigDict
44
+ from starlette.responses import Response
45
+
46
+ from .config_client import ConfigClient
47
+ from .logging import configure_logging, get_logger
48
+
49
+ logger = get_logger(__name__)
50
+
51
+ T = TypeVar("T", bound=BaseSettings)
52
+
53
+
54
+ class BaseAnalyzerSettings(BaseSettings):
55
+ """Common settings shared by all SPOT analyzers.
56
+
57
+ Analyzers extend this class with their own specific fields::
58
+
59
+ class Settings(BaseAnalyzerSettings):
60
+ app_name: str = "analyzer-nlp"
61
+ model_path: str = "/models/distilbert"
62
+ use_gpu: bool = False
63
+
64
+ Fields:
65
+ app_name: Human-readable analyzer name
66
+ app_version: Semantic version string
67
+ debug: Enable debug mode (hot reload, verbose logging)
68
+ log_level: Python log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
69
+ host: Server bind address
70
+ port: Server bind port
71
+ platform_url: URL of the SPOT API gateway for config fetching
72
+ """
73
+
74
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
75
+
76
+ app_name: str = "spot-analyzer"
77
+ app_version: str = "1.0.0"
78
+ debug: bool = False
79
+ log_level: str = "INFO"
80
+
81
+ # Server
82
+ host: str = "0.0.0.0"
83
+ port: int = 8000
84
+
85
+ # Platform URL for ConfigClient
86
+ platform_url: str = "http://api-gateway:8000"
87
+
88
+
89
+ def settings_singleton(func: Callable[[], T]) -> Callable[[], T]:
90
+ """Decorator that turns a settings factory into a cached singleton.
91
+
92
+ Usage::
93
+
94
+ @settings_singleton
95
+ def get_settings() -> Settings:
96
+ return Settings()
97
+
98
+ # First call creates the instance, subsequent calls return cached value
99
+ settings = get_settings()
100
+
101
+ The cache can be cleared by setting the internal list element to None::
102
+
103
+ get_settings._instance[0] = None
104
+ """
105
+ instance: list[T | None] = [None]
106
+
107
+ @wraps(func)
108
+ def wrapper() -> T:
109
+ if instance[0] is None:
110
+ instance[0] = func()
111
+ result = instance[0]
112
+ assert result is not None
113
+ return result
114
+
115
+ # Allow cache clearing for testing
116
+ wrapper._instance = instance # type: ignore[attr-defined]
117
+ return wrapper
118
+
119
+
120
+ def merge_settings(base: T, overrides: dict[str, Any]) -> T:
121
+ """Merge base settings with central config overrides.
122
+
123
+ Only applies overrides for fields that actually exist in the Settings model.
124
+ Returns a new Settings instance (the original is not mutated).
125
+
126
+ Args:
127
+ base: Base Settings instance from local env/defaults
128
+ overrides: Dict of override values from central config
129
+
130
+ Returns:
131
+ New Settings instance with overrides applied.
132
+ """
133
+ valid_keys = set(type(base).model_fields.keys())
134
+ filtered = {k: v for k, v in overrides.items() if k in valid_keys}
135
+ return base.model_copy(update=filtered)
136
+
137
+
138
+ class AnalyzerState:
139
+ """Shared mutable state for an analyzer application.
140
+
141
+ Holds the settings, config client, and analysis service references
142
+ that are initialized during the lifespan and used by endpoints.
143
+
144
+ Attributes:
145
+ settings: Current merged settings (None before startup)
146
+ config_client: ConfigClient for central config fetching
147
+ analysis_service: The analyzer's AnalysisService instance (None before startup)
148
+ settings_factory: Callable that returns a fresh Settings instance
149
+ on_reload: Optional async callback invoked after config reload
150
+ """
151
+
152
+ def __init__(
153
+ self,
154
+ config_client: ConfigClient,
155
+ settings_factory: Callable[[], BaseAnalyzerSettings],
156
+ ) -> None:
157
+ self.settings: BaseAnalyzerSettings | None = None
158
+ self.config_client = config_client
159
+ self.analysis_service: Any = None
160
+ self.settings_factory = settings_factory
161
+ self.on_reload: Callable[[], Awaitable[None]] | None = None
162
+
163
+ def apply_settings(self) -> BaseAnalyzerSettings:
164
+ """Load base settings, merge with central overrides, configure logging.
165
+
166
+ Updates ``self.settings`` with the merged result and configures
167
+ the logging level. Does NOT touch ``self.analysis_service`` --
168
+ that is the caller's responsibility.
169
+
170
+ Returns:
171
+ Merged settings instance.
172
+ """
173
+ base = self.settings_factory()
174
+ overrides = self.config_client.config
175
+ self.settings = merge_settings(base, overrides)
176
+ configure_logging(log_level=self.settings.log_level)
177
+ return self.settings
178
+
179
+
180
+ def create_analyzer_app(
181
+ *,
182
+ title: str,
183
+ description: str,
184
+ analyzer_id: str,
185
+ settings_factory: Callable[[], BaseAnalyzerSettings],
186
+ platform_url: str | None = None,
187
+ lifespan: Any | None = None,
188
+ ) -> tuple[FastAPI, AnalyzerState]:
189
+ """Create a FastAPI app with standard analyzer endpoints.
190
+
191
+ Returns the app and a state object. The caller is responsible for:
192
+
193
+ 1. Adding a lifespan or startup handler that calls
194
+ ``state.config_client.fetch_config()``, ``state.apply_settings()``,
195
+ and sets ``state.analysis_service``.
196
+ 2. Adding a ``/health`` endpoint (health checks are analyzer-specific).
197
+ 3. Adding a ``/internal/analyze`` endpoint (pre-checks are analyzer-specific).
198
+ 4. Optionally setting ``state.on_reload`` for post-reload logic.
199
+
200
+ Standard endpoints provided:
201
+
202
+ - GET /capabilities -- delegates to analysis_service.get_capabilities()
203
+ - GET /config -- delegates to analysis_service.get_configuration()
204
+ - POST /admin/reload-config -- reloads central config, calls state.on_reload if set
205
+
206
+ Args:
207
+ title: FastAPI app title
208
+ description: FastAPI app description
209
+ analyzer_id: Unique analyzer identifier for config client
210
+ settings_factory: Callable returning a Settings instance
211
+ platform_url: Override platform URL (default: from settings or env)
212
+ lifespan: Optional lifespan async context manager for the app
213
+
214
+ Returns:
215
+ Tuple of (FastAPI app, AnalyzerState).
216
+ """
217
+ initial_settings = settings_factory()
218
+
219
+ # Determine platform URL
220
+ if platform_url is None:
221
+ _platform_url = getattr(
222
+ initial_settings,
223
+ "platform_url",
224
+ os.getenv("SPOT_PLATFORM_URL", "http://api-gateway:8000"),
225
+ )
226
+ else:
227
+ _platform_url = platform_url
228
+
229
+ default_version = initial_settings.app_version
230
+
231
+ config_client = ConfigClient(
232
+ analyzer_id=analyzer_id,
233
+ platform_url=_platform_url,
234
+ )
235
+
236
+ state = AnalyzerState(
237
+ config_client=config_client,
238
+ settings_factory=settings_factory,
239
+ )
240
+
241
+ app_kwargs: dict[str, Any] = {
242
+ "title": title,
243
+ "description": description,
244
+ "version": default_version,
245
+ }
246
+ if lifespan is not None:
247
+ app_kwargs["lifespan"] = lifespan
248
+
249
+ app = FastAPI(**app_kwargs)
250
+
251
+ # --- Standard endpoints ---
252
+
253
+ @app.get("/capabilities")
254
+ async def get_capabilities() -> list[str]:
255
+ """Get analyzer capabilities."""
256
+ if state.analysis_service is None:
257
+ return []
258
+ capabilities = await state.analysis_service.get_capabilities()
259
+ return [cap.value for cap in capabilities]
260
+
261
+ @app.get("/config")
262
+ async def get_configuration() -> dict[str, Any]:
263
+ """Get analyzer configuration."""
264
+ if state.analysis_service is None:
265
+ return {}
266
+ config: dict[str, Any] = await state.analysis_service.get_configuration()
267
+ return config
268
+
269
+ @app.post("/admin/reload-config", status_code=204, response_class=Response)
270
+ async def reload_config() -> Response:
271
+ """Reload configuration from central platform.
272
+
273
+ Called by spot-cli config reload when analyzer config changes.
274
+ Fetches fresh central overrides, re-applies settings, and
275
+ invokes the on_reload callback if one is registered.
276
+ """
277
+ logger.info("Reloading configuration...")
278
+
279
+ # Fetch fresh central overrides
280
+ await config_client.reload()
281
+
282
+ # Apply settings
283
+ state.apply_settings()
284
+
285
+ # Invoke analyzer-specific post-reload logic
286
+ if state.on_reload is not None:
287
+ await state.on_reload()
288
+
289
+ logger.info(
290
+ "Configuration reloaded (version: %s)",
291
+ config_client.version or "local-only",
292
+ )
293
+ return Response(status_code=204)
294
+
295
+ return app, state
296
+
297
+
298
+ def run_analyzer(app_import_path: str, settings: BaseAnalyzerSettings) -> None:
299
+ """Run an analyzer with uvicorn using standard settings.
300
+
301
+ Intended for use in ``if __name__ == "__main__":`` blocks::
302
+
303
+ if __name__ == "__main__":
304
+ run_analyzer("src.main:app", get_settings())
305
+
306
+ Args:
307
+ app_import_path: Dotted import path to the app (e.g. "src.main:app")
308
+ settings: Settings instance for host/port/log_level/debug
309
+ """
310
+ uvicorn.run(
311
+ app_import_path,
312
+ host=settings.host,
313
+ port=settings.port,
314
+ log_level=settings.log_level.lower(),
315
+ reload=settings.debug,
316
+ )