pulse-engine 0.2.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.
Files changed (150) hide show
  1. pulse_engine/__init__.py +0 -0
  2. pulse_engine/adapters/__init__.py +58 -0
  3. pulse_engine/adapters/audio_transcription.py +167 -0
  4. pulse_engine/adapters/batcher.py +36 -0
  5. pulse_engine/adapters/digital_news.py +128 -0
  6. pulse_engine/adapters/digital_news_metadata.py +536 -0
  7. pulse_engine/adapters/exceptions.py +10 -0
  8. pulse_engine/adapters/models.py +134 -0
  9. pulse_engine/adapters/opensearch_storage.py +160 -0
  10. pulse_engine/adapters/speech_content.py +130 -0
  11. pulse_engine/adapters/speech_metadata.py +374 -0
  12. pulse_engine/adapters/twitter.py +423 -0
  13. pulse_engine/adapters/youtube_downloader.py +186 -0
  14. pulse_engine/adapters/youtube_metadata.py +261 -0
  15. pulse_engine/api/__init__.py +0 -0
  16. pulse_engine/api/v1/__init__.py +0 -0
  17. pulse_engine/api/v1/auth.py +91 -0
  18. pulse_engine/api/v1/health.py +62 -0
  19. pulse_engine/api/v1/router.py +16 -0
  20. pulse_engine/chain_recovery.py +131 -0
  21. pulse_engine/cli/__init__.py +0 -0
  22. pulse_engine/cli/main.py +169 -0
  23. pulse_engine/cli/templates/cookiecutter.json +4 -0
  24. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/.gitignore +13 -0
  25. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/Dockerfile +32 -0
  26. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/pipeline.yaml +17 -0
  27. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/pyproject.toml +25 -0
  28. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/src/pulse_{{cookiecutter.product_slug}}/__init__.py +8 -0
  29. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/__init__.py +0 -0
  30. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/unit/__init__.py +0 -0
  31. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/unit/test_manifest.py +15 -0
  32. pulse_engine/client.py +95 -0
  33. pulse_engine/config.py +157 -0
  34. pulse_engine/core/__init__.py +0 -0
  35. pulse_engine/core/error_handlers.py +64 -0
  36. pulse_engine/core/exceptions.py +67 -0
  37. pulse_engine/core/job_token.py +109 -0
  38. pulse_engine/core/logging.py +45 -0
  39. pulse_engine/core/scope.py +23 -0
  40. pulse_engine/core/security.py +130 -0
  41. pulse_engine/database.py +30 -0
  42. pulse_engine/dependencies.py +166 -0
  43. pulse_engine/deployment/__init__.py +0 -0
  44. pulse_engine/deployment/backend_deployment_repository.py +83 -0
  45. pulse_engine/deployment/backends/__init__.py +0 -0
  46. pulse_engine/deployment/backends/base.py +50 -0
  47. pulse_engine/deployment/backends/exceptions.py +20 -0
  48. pulse_engine/deployment/backends/native_lambda.py +125 -0
  49. pulse_engine/deployment/backends/prefect_ecs.py +116 -0
  50. pulse_engine/deployment/backends/prefect_k8s.py +131 -0
  51. pulse_engine/deployment/backends/registry.py +50 -0
  52. pulse_engine/deployment/infra_provisioner.py +285 -0
  53. pulse_engine/deployment/job_launcher.py +178 -0
  54. pulse_engine/deployment/models.py +48 -0
  55. pulse_engine/deployment/repository.py +54 -0
  56. pulse_engine/deployment/router.py +22 -0
  57. pulse_engine/deployment/schemas.py +18 -0
  58. pulse_engine/deployment/service.py +65 -0
  59. pulse_engine/extractor/__init__.py +0 -0
  60. pulse_engine/extractor/adapters/__init__.py +0 -0
  61. pulse_engine/extractor/base.py +48 -0
  62. pulse_engine/extractor/models.py +50 -0
  63. pulse_engine/extractor/orchestrator/__init__.py +15 -0
  64. pulse_engine/extractor/orchestrator/base.py +34 -0
  65. pulse_engine/extractor/orchestrator/noop.py +37 -0
  66. pulse_engine/extractor/orchestrator/prefect.py +163 -0
  67. pulse_engine/extractor/repository.py +163 -0
  68. pulse_engine/extractor/router.py +102 -0
  69. pulse_engine/extractor/schemas.py +93 -0
  70. pulse_engine/extractor/service.py +431 -0
  71. pulse_engine/extractor/stage_models.py +36 -0
  72. pulse_engine/extractor/stage_repository.py +109 -0
  73. pulse_engine/main.py +195 -0
  74. pulse_engine/mcp/__init__.py +0 -0
  75. pulse_engine/mcp/__main__.py +5 -0
  76. pulse_engine/mcp/server.py +108 -0
  77. pulse_engine/mcp/tools_jobs.py +159 -0
  78. pulse_engine/mcp/tools_kb.py +88 -0
  79. pulse_engine/mcp/tools_modules.py +115 -0
  80. pulse_engine/mcp/tools_pipelines.py +215 -0
  81. pulse_engine/mcp/tools_processor.py +208 -0
  82. pulse_engine/middleware/__init__.py +0 -0
  83. pulse_engine/middleware/rate_limit.py +144 -0
  84. pulse_engine/middleware/request_id.py +16 -0
  85. pulse_engine/middleware/security_headers.py +25 -0
  86. pulse_engine/middleware/tenant.py +90 -0
  87. pulse_engine/pipeline/__init__.py +0 -0
  88. pulse_engine/pipeline/config_parser.py +148 -0
  89. pulse_engine/pipeline/expression.py +268 -0
  90. pulse_engine/pipeline/models.py +98 -0
  91. pulse_engine/pipeline/repositories.py +224 -0
  92. pulse_engine/pipeline/router_modules.py +66 -0
  93. pulse_engine/pipeline/router_pipelines.py +198 -0
  94. pulse_engine/pipeline/schemas.py +200 -0
  95. pulse_engine/pipeline/service.py +250 -0
  96. pulse_engine/pipeline/translators/__init__.py +44 -0
  97. pulse_engine/pipeline/translators/airflow_status.py +11 -0
  98. pulse_engine/pipeline/translators/airflow_translator.py +22 -0
  99. pulse_engine/pipeline/translators/base.py +42 -0
  100. pulse_engine/pipeline/translators/prefect_status.py +93 -0
  101. pulse_engine/pipeline/translators/prefect_translator.py +195 -0
  102. pulse_engine/processor/__init__.py +0 -0
  103. pulse_engine/processor/base.py +36 -0
  104. pulse_engine/processor/core/__init__.py +0 -0
  105. pulse_engine/processor/core/analysis.py +148 -0
  106. pulse_engine/processor/core/chunking.py +158 -0
  107. pulse_engine/processor/core/prompts.py +340 -0
  108. pulse_engine/processor/core/topic_splitter.py +105 -0
  109. pulse_engine/processor/defaults/__init__.py +11 -0
  110. pulse_engine/processor/defaults/core_processor.py +12 -0
  111. pulse_engine/processor/defaults/postprocessor.py +12 -0
  112. pulse_engine/processor/defaults/preprocessor.py +12 -0
  113. pulse_engine/processor/llm/__init__.py +0 -0
  114. pulse_engine/processor/llm/provider.py +58 -0
  115. pulse_engine/processor/ocr/gemini.py +52 -0
  116. pulse_engine/processor/pipeline.py +107 -0
  117. pulse_engine/processor/postprocessor/__init__.py +0 -0
  118. pulse_engine/processor/postprocessor/embeddings.py +34 -0
  119. pulse_engine/processor/postprocessor/tasks.py +180 -0
  120. pulse_engine/processor/preprocessor/__init__.py +0 -0
  121. pulse_engine/processor/preprocessor/tasks.py +71 -0
  122. pulse_engine/processor/router.py +192 -0
  123. pulse_engine/processor/schemas.py +167 -0
  124. pulse_engine/registry.py +117 -0
  125. pulse_engine/runners/__init__.py +0 -0
  126. pulse_engine/runners/lambda_runner.py +26 -0
  127. pulse_engine/runners/pipeline_runner.py +43 -0
  128. pulse_engine/runners/prefect_pipeline_flow.py +904 -0
  129. pulse_engine/runners/prefect_runner.py +33 -0
  130. pulse_engine/s3.py +72 -0
  131. pulse_engine/secrets.py +46 -0
  132. pulse_engine/services/__init__.py +0 -0
  133. pulse_engine/services/bootstrap.py +211 -0
  134. pulse_engine/services/opensearch.py +84 -0
  135. pulse_engine/storage/__init__.py +0 -0
  136. pulse_engine/storage/connectors/__init__.py +0 -0
  137. pulse_engine/storage/connectors/athena.py +226 -0
  138. pulse_engine/storage/connectors/base.py +32 -0
  139. pulse_engine/storage/connectors/opensearch.py +344 -0
  140. pulse_engine/storage/knowledge_base.py +68 -0
  141. pulse_engine/storage/router.py +78 -0
  142. pulse_engine/storage/schemas.py +93 -0
  143. pulse_engine/testing/__init__.py +13 -0
  144. pulse_engine/testing/fixtures.py +50 -0
  145. pulse_engine/testing/mocks.py +104 -0
  146. pulse_engine/worker.py +53 -0
  147. pulse_engine-0.2.0.dist-info/METADATA +654 -0
  148. pulse_engine-0.2.0.dist-info/RECORD +150 -0
  149. pulse_engine-0.2.0.dist-info/WHEEL +4 -0
  150. pulse_engine-0.2.0.dist-info/entry_points.txt +4 -0
pulse_engine/main.py ADDED
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncIterator
5
+ from contextlib import asynccontextmanager
6
+ from typing import TYPE_CHECKING
7
+
8
+ from fastapi import FastAPI
9
+ from starlette.middleware.cors import CORSMiddleware
10
+
11
+ from pulse_engine.api.v1.router import v1_router
12
+ from pulse_engine.config import Settings, get_settings
13
+ from pulse_engine.core.error_handlers import register_exception_handlers
14
+ from pulse_engine.core.security import (
15
+ CognitoTokenVerifier,
16
+ MockTokenVerifier,
17
+ TokenVerifier,
18
+ )
19
+ from pulse_engine.middleware.rate_limit import RateLimitMiddleware
20
+ from pulse_engine.middleware.request_id import RequestIDMiddleware
21
+ from pulse_engine.middleware.security_headers import SecurityHeadersMiddleware
22
+ from pulse_engine.middleware.tenant import TenantMiddleware
23
+ from pulse_engine.pipeline.router_modules import router as pipeline_modules_router
24
+ from pulse_engine.pipeline.router_pipelines import router as pipeline_pipelines_router
25
+ from pulse_engine.services.bootstrap import bootstrap_services
26
+
27
+ if TYPE_CHECKING:
28
+ from pulse_engine.registry import ProductManifest
29
+
30
+
31
+ @asynccontextmanager
32
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
33
+ settings: Settings = app.state.settings
34
+ manifest: ProductManifest | None = getattr(app.state, "manifest", None)
35
+
36
+ async with bootstrap_services(settings, manifest) as container:
37
+ app.state.opensearch = container.opensearch
38
+ app.state.knowledge_base = container.kb_service
39
+ app.state.db_session_factory = container.db_session_factory
40
+ app.state.orchestrator_adapter = container.orchestrator_adapter
41
+ app.state.celery_app = container.celery_app
42
+ app.state.pipeline = container.pipeline
43
+ app.state.s3_client = container.s3_client
44
+ app.state.token_issuer = container.token_issuer
45
+ app.state.pipeline_translators = container.pipeline_translators
46
+ app.state.pipeline_status_providers = container.pipeline_status_providers
47
+
48
+ # Start chain recovery polling if configured
49
+ recovery_task = None
50
+ if (
51
+ container.db_session_factory
52
+ and container.token_issuer
53
+ and settings.pulse_engine_url
54
+ and settings.pulse_job_token_secret
55
+ ):
56
+
57
+ async def _recovery_loop() -> None:
58
+ from pulse_engine.chain_recovery import ChainRecoveryTask
59
+ from pulse_engine.deployment.backend_deployment_repository import (
60
+ BackendDeploymentRepository,
61
+ )
62
+ from pulse_engine.deployment.backends.registry import BackendRegistry
63
+ from pulse_engine.deployment.job_launcher import JobLauncher
64
+ from pulse_engine.deployment.repository import (
65
+ RegistrationRepository,
66
+ )
67
+ from pulse_engine.extractor.repository import JobRepository
68
+ from pulse_engine.extractor.stage_repository import (
69
+ StageRepository,
70
+ )
71
+
72
+ registry = BackendRegistry(settings)
73
+ factory = container.db_session_factory
74
+ assert factory is not None
75
+ while True:
76
+ try:
77
+ async with factory() as session:
78
+ job_launcher = JobLauncher(
79
+ registration_repo=RegistrationRepository(session),
80
+ backend_deployment_repo=BackendDeploymentRepository(
81
+ session
82
+ ),
83
+ registry=registry,
84
+ token_issuer=container.token_issuer,
85
+ settings=settings,
86
+ )
87
+ task = ChainRecoveryTask(
88
+ stage_repo=StageRepository(session),
89
+ job_repo=JobRepository(session),
90
+ job_launcher=job_launcher,
91
+ orchestrator=container.orchestrator_adapter,
92
+ settings=settings,
93
+ )
94
+ await task.check_once()
95
+ await task.check_stalled_chains()
96
+ except Exception:
97
+ import structlog
98
+
99
+ structlog.get_logger().warning(
100
+ "chain_recovery_loop_error",
101
+ exc_info=True,
102
+ )
103
+ await asyncio.sleep(60)
104
+
105
+ recovery_task = asyncio.create_task(_recovery_loop())
106
+
107
+ try:
108
+ yield
109
+ finally:
110
+ if recovery_task:
111
+ recovery_task.cancel()
112
+
113
+
114
+ def _build_verifier(settings: Settings) -> TokenVerifier:
115
+ if settings.mock_jwt_secret:
116
+ return MockTokenVerifier(
117
+ secret=settings.mock_jwt_secret,
118
+ audience=settings.cognito_app_client_id,
119
+ issuer=settings.cognito_issuer,
120
+ )
121
+ return CognitoTokenVerifier(
122
+ jwks_url=settings.cognito_jwks_url,
123
+ issuer=settings.cognito_issuer,
124
+ audience=settings.cognito_app_client_id,
125
+ )
126
+
127
+
128
+ def create_app(
129
+ settings: Settings | None = None,
130
+ token_verifier: TokenVerifier | None = None,
131
+ manifest: ProductManifest | None = None,
132
+ ) -> FastAPI:
133
+ if settings is None:
134
+ settings = get_settings()
135
+
136
+ if manifest:
137
+ from pulse_engine.registry import ManifestValidationError, validate_manifest
138
+
139
+ errors = validate_manifest(manifest)
140
+ if errors:
141
+ raise ManifestValidationError(errors)
142
+
143
+ app = FastAPI(
144
+ title="Pulse Platform",
145
+ version=settings.app_version,
146
+ lifespan=lifespan,
147
+ docs_url=None if settings.app_env == "production" else "/docs",
148
+ redoc_url=None if settings.app_env == "production" else "/redoc",
149
+ openapi_url=None if settings.app_env == "production" else "/openapi.json",
150
+ )
151
+
152
+ app.state.settings = settings
153
+ app.state.manifest = manifest
154
+
155
+ register_exception_handlers(app)
156
+
157
+ verifier = token_verifier or _build_verifier(settings)
158
+
159
+ # Middleware is applied in reverse order — outermost first
160
+ app.add_middleware(
161
+ TenantMiddleware,
162
+ verifier=verifier,
163
+ job_token_secret=settings.pulse_job_token_secret,
164
+ )
165
+ app.add_middleware(SecurityHeadersMiddleware)
166
+ app.add_middleware(RequestIDMiddleware)
167
+ app.add_middleware(
168
+ CORSMiddleware,
169
+ allow_origins=(
170
+ ["*"]
171
+ if settings.app_env == "development"
172
+ else [settings.pulse_engine_url]
173
+ if settings.pulse_engine_url
174
+ else []
175
+ ),
176
+ allow_credentials=True,
177
+ allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
178
+ allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
179
+ expose_headers=["X-Request-ID"],
180
+ )
181
+ app.add_middleware(
182
+ RateLimitMiddleware,
183
+ max_requests=100,
184
+ window_seconds=60,
185
+ )
186
+
187
+ app.include_router(v1_router)
188
+ app.include_router(pipeline_modules_router)
189
+ app.include_router(pipeline_pipelines_router)
190
+
191
+ if manifest:
192
+ for router in manifest.routers:
193
+ app.include_router(router, prefix="/api/v1")
194
+
195
+ return app
File without changes
@@ -0,0 +1,5 @@
1
+ import asyncio
2
+
3
+ from pulse_engine.mcp.server import run_mcp_server
4
+
5
+ asyncio.run(run_mcp_server())
@@ -0,0 +1,108 @@
1
+ """MCP server for Pulse Platform.
2
+
3
+ Exposes KB, Jobs, Processor, Pipeline, and Module tools.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import functools
9
+ import importlib
10
+ from collections.abc import Callable
11
+ from typing import Any, TypeVar
12
+
13
+ from mcp.server.fastmcp import FastMCP
14
+ from pydantic import ValidationError
15
+
16
+ from pulse_engine.config import get_settings as _get_settings
17
+ from pulse_engine.core.exceptions import (
18
+ AppError,
19
+ NotFoundError,
20
+ ServiceUnavailableError,
21
+ TooManyRequestsError,
22
+ )
23
+ from pulse_engine.services.bootstrap import ServiceContainer
24
+
25
+ _s = _get_settings()
26
+ mcp = FastMCP("Pulse Platform", host=_s.mcp_sse_host, port=_s.mcp_sse_port)
27
+
28
+ _container: ServiceContainer | None = None
29
+
30
+
31
+ def get_container() -> ServiceContainer:
32
+ if _container is None:
33
+ raise RuntimeError("MCP server not initialized — no service container")
34
+ return _container
35
+
36
+
37
+ F = TypeVar("F", bound=Callable[..., Any])
38
+
39
+
40
+ def handle_mcp_errors(fn: F) -> F:
41
+ @functools.wraps(fn)
42
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
43
+ try:
44
+ return await fn(*args, **kwargs)
45
+ except NotFoundError as e:
46
+ return {"error": True, "error_type": "NotFoundError", "message": e.message}
47
+ except TooManyRequestsError as e:
48
+ return {
49
+ "error": True,
50
+ "error_type": "TooManyRequestsError",
51
+ "message": e.message,
52
+ }
53
+ except ServiceUnavailableError as e:
54
+ return {
55
+ "error": True,
56
+ "error_type": "ServiceUnavailableError",
57
+ "message": e.message,
58
+ }
59
+ except ValidationError as e:
60
+ return {
61
+ "error": True,
62
+ "error_type": "ValidationError",
63
+ "message": str(e),
64
+ }
65
+ except AppError as e:
66
+ return {
67
+ "error": True,
68
+ "error_type": type(e).__name__,
69
+ "message": e.message,
70
+ }
71
+
72
+ return wrapper # type: ignore[return-value]
73
+
74
+
75
+ def create_mcp_server(
76
+ manifest: Any | None = None,
77
+ ) -> FastMCP:
78
+ """Return the module-level FastMCP instance, optionally loading product tools."""
79
+ if manifest:
80
+ for module_path in manifest.mcp_tool_modules:
81
+ importlib.import_module(module_path)
82
+ return mcp
83
+
84
+
85
+ async def run_mcp_server(manifest: Any | None = None) -> None:
86
+ global _container
87
+
88
+ from pulse_engine.config import get_settings
89
+ from pulse_engine.services.bootstrap import bootstrap_services
90
+
91
+ settings = get_settings()
92
+
93
+ async with bootstrap_services(settings, manifest) as container:
94
+ _container = container
95
+
96
+ # Import engine tool modules to trigger @mcp.tool() registrations
97
+ import pulse_engine.mcp.tools_jobs # noqa: F401
98
+ import pulse_engine.mcp.tools_kb # noqa: F401
99
+ import pulse_engine.mcp.tools_modules # noqa: F401
100
+ import pulse_engine.mcp.tools_pipelines # noqa: F401
101
+ import pulse_engine.mcp.tools_processor # noqa: F401
102
+
103
+ # Import product tool modules
104
+ if manifest:
105
+ for module_path in manifest.mcp_tool_modules:
106
+ importlib.import_module(module_path)
107
+
108
+ await mcp.run_sse_async()
@@ -0,0 +1,159 @@
1
+ """MCP tools for Job operations."""
2
+
3
+ from typing import Any
4
+
5
+ from pulse_engine.core.exceptions import ServiceUnavailableError
6
+ from pulse_engine.extractor.repository import JobRepository
7
+ from pulse_engine.extractor.schemas import (
8
+ CreateJobRequest,
9
+ JobPriority,
10
+ StatusUpdateRequest,
11
+ )
12
+ from pulse_engine.extractor.service import JobService
13
+ from pulse_engine.mcp.server import get_container, handle_mcp_errors, mcp
14
+
15
+
16
+ def _build_job_service(
17
+ session: Any,
18
+ container: Any,
19
+ ) -> JobService:
20
+ repo = JobRepository(session)
21
+ return JobService(
22
+ repository=repo,
23
+ orchestrator=container.orchestrator_adapter,
24
+ settings=container.settings,
25
+ )
26
+
27
+
28
+ @mcp.tool()
29
+ @handle_mcp_errors
30
+ async def jobs_register(
31
+ tenant_id: str,
32
+ job_type: str,
33
+ product: str,
34
+ priority: str = "normal",
35
+ orchestrator_run_id: str | None = None,
36
+ parameters: dict[str, Any] | None = None,
37
+ callback_url: str | None = None,
38
+ ) -> dict[str, Any]:
39
+ """Register a new extraction job for a tenant."""
40
+ container = get_container()
41
+ if container.db_session_factory is None:
42
+ raise ServiceUnavailableError("Database not configured")
43
+ async with container.db_session_factory() as session:
44
+ service = _build_job_service(session, container)
45
+ request = CreateJobRequest(
46
+ job_type=job_type,
47
+ product=product,
48
+ priority=JobPriority(priority),
49
+ orchestrator_run_id=orchestrator_run_id,
50
+ parameters=parameters or {},
51
+ callback_url=callback_url,
52
+ )
53
+ result = await service.register_job(tenant_id, request)
54
+ return result.model_dump(mode="json")
55
+
56
+
57
+ @mcp.tool()
58
+ @handle_mcp_errors
59
+ async def jobs_get(
60
+ tenant_id: str,
61
+ job_id: str,
62
+ ) -> dict[str, Any]:
63
+ """Get details of a specific job."""
64
+ container = get_container()
65
+ if container.db_session_factory is None:
66
+ raise ServiceUnavailableError("Database not configured")
67
+ async with container.db_session_factory() as session:
68
+ service = _build_job_service(session, container)
69
+ result = await service.get_job(tenant_id, job_id)
70
+ return result.model_dump(mode="json")
71
+
72
+
73
+ @mcp.tool()
74
+ @handle_mcp_errors
75
+ async def jobs_list(
76
+ tenant_id: str,
77
+ status: str | None = None,
78
+ product: str | None = None,
79
+ job_type: str | None = None,
80
+ limit: int = 20,
81
+ offset: int = 0,
82
+ sort_by: str = "created_at",
83
+ order: str = "desc",
84
+ ) -> dict[str, Any]:
85
+ """List jobs for a tenant with optional filters."""
86
+ container = get_container()
87
+ if container.db_session_factory is None:
88
+ raise ServiceUnavailableError("Database not configured")
89
+ async with container.db_session_factory() as session:
90
+ service = _build_job_service(session, container)
91
+ result = await service.list_jobs(
92
+ tenant_id,
93
+ status=status,
94
+ product=product,
95
+ job_type=job_type,
96
+ limit=limit,
97
+ offset=offset,
98
+ sort_by=sort_by,
99
+ order=order,
100
+ )
101
+ return result.model_dump(mode="json")
102
+
103
+
104
+ @mcp.tool()
105
+ @handle_mcp_errors
106
+ async def jobs_push_status(
107
+ tenant_id: str,
108
+ job_id: str,
109
+ status: str,
110
+ result_summary: dict[str, Any] | None = None,
111
+ error: str | None = None,
112
+ ) -> dict[str, Any]:
113
+ """Push a status update for a job."""
114
+ container = get_container()
115
+ if container.db_session_factory is None:
116
+ raise ServiceUnavailableError("Database not configured")
117
+ async with container.db_session_factory() as session:
118
+ service = _build_job_service(session, container)
119
+ from pulse_engine.extractor.schemas import JobStatus
120
+
121
+ request = StatusUpdateRequest(
122
+ status=JobStatus(status),
123
+ result_summary=result_summary,
124
+ error=error,
125
+ )
126
+ result = await service.push_status(tenant_id, job_id, request)
127
+ return result.model_dump(mode="json")
128
+
129
+
130
+ @mcp.tool()
131
+ @handle_mcp_errors
132
+ async def jobs_cancel(
133
+ tenant_id: str,
134
+ job_id: str,
135
+ ) -> dict[str, Any]:
136
+ """Cancel a running or pending job."""
137
+ container = get_container()
138
+ if container.db_session_factory is None:
139
+ raise ServiceUnavailableError("Database not configured")
140
+ async with container.db_session_factory() as session:
141
+ service = _build_job_service(session, container)
142
+ result = await service.cancel_job(tenant_id, job_id)
143
+ return result.model_dump(mode="json")
144
+
145
+
146
+ @mcp.tool()
147
+ @handle_mcp_errors
148
+ async def jobs_delete(
149
+ tenant_id: str,
150
+ job_id: str,
151
+ ) -> dict[str, Any]:
152
+ """Delete a job record."""
153
+ container = get_container()
154
+ if container.db_session_factory is None:
155
+ raise ServiceUnavailableError("Database not configured")
156
+ async with container.db_session_factory() as session:
157
+ service = _build_job_service(session, container)
158
+ deleted = await service.delete_job(tenant_id, job_id)
159
+ return {"deleted": deleted, "job_id": job_id}
@@ -0,0 +1,88 @@
1
+ """MCP tools for Knowledge Base operations."""
2
+
3
+ from typing import Any
4
+
5
+ from pulse_engine.mcp.server import get_container, handle_mcp_errors, mcp
6
+ from pulse_engine.storage.schemas import Document, SearchQuery, SearchQueryType
7
+
8
+
9
+ @mcp.tool()
10
+ @handle_mcp_errors
11
+ async def kb_store_documents(
12
+ tenant_id: str,
13
+ documents: list[dict[str, Any]],
14
+ ) -> dict[str, Any]:
15
+ """Store documents in the Knowledge Base for a tenant."""
16
+ container = get_container()
17
+ docs = [Document(**d) for d in documents]
18
+ result = await container.kb_service.store_documents(tenant_id, docs)
19
+ return result.model_dump(mode="json")
20
+
21
+
22
+ @mcp.tool()
23
+ @handle_mcp_errors
24
+ async def kb_retrieve_document(
25
+ tenant_id: str,
26
+ document_id: str,
27
+ ) -> dict[str, Any]:
28
+ """Retrieve a single document by ID from the Knowledge Base."""
29
+ container = get_container()
30
+ doc = await container.kb_service.retrieve_document(tenant_id, document_id)
31
+ return doc.model_dump(mode="json")
32
+
33
+
34
+ @mcp.tool()
35
+ @handle_mcp_errors
36
+ async def kb_search(
37
+ tenant_id: str,
38
+ query_text: str,
39
+ query_type: str = "text",
40
+ filters: dict[str, Any] | None = None,
41
+ size: int = 10,
42
+ ) -> dict[str, Any]:
43
+ """Search documents in the Knowledge Base."""
44
+ container = get_container()
45
+ query = SearchQuery(
46
+ query_type=SearchQueryType(query_type),
47
+ query_text=query_text,
48
+ filters=filters or {},
49
+ size=size,
50
+ )
51
+ result = await container.kb_service.search(tenant_id, query)
52
+ return result.model_dump(mode="json")
53
+
54
+
55
+ @mcp.tool()
56
+ @handle_mcp_errors
57
+ async def kb_delete_document(
58
+ tenant_id: str,
59
+ document_id: str,
60
+ ) -> dict[str, Any]:
61
+ """Delete a document from the Knowledge Base."""
62
+ container = get_container()
63
+ await container.kb_service.delete_document(tenant_id, document_id)
64
+ return {"deleted": True, "document_id": document_id}
65
+
66
+
67
+ @mcp.tool()
68
+ @handle_mcp_errors
69
+ async def kb_get_stats(
70
+ tenant_id: str,
71
+ ) -> dict[str, Any]:
72
+ """Get Knowledge Base statistics for a tenant."""
73
+ container = get_container()
74
+ stats = await container.kb_service.get_stats(tenant_id)
75
+ return stats.model_dump(mode="json")
76
+
77
+
78
+ @mcp.tool()
79
+ @handle_mcp_errors
80
+ async def kb_run_query(
81
+ tenant_id: str,
82
+ sql: str,
83
+ parameters: dict[str, Any] | None = None,
84
+ ) -> dict[str, Any]:
85
+ """Execute an Athena SQL query against the Knowledge Base."""
86
+ container = get_container()
87
+ result = await container.kb_service.execute_query(tenant_id, sql, parameters)
88
+ return result.model_dump(mode="json")
@@ -0,0 +1,115 @@
1
+ """MCP tools for Module Registry operations."""
2
+
3
+ from typing import Any
4
+
5
+ from pulse_engine.core.exceptions import NotFoundError, ServiceUnavailableError
6
+ from pulse_engine.mcp.server import get_container, handle_mcp_errors, mcp
7
+ from pulse_engine.pipeline.repositories import ModuleRegistryRepository
8
+
9
+
10
+ @mcp.tool()
11
+ @handle_mcp_errors
12
+ async def modules_register(
13
+ tenant_id: str,
14
+ product: str,
15
+ modules: dict[str, dict[str, str]],
16
+ ) -> dict[str, Any]:
17
+ """Register pipeline module images for a product.
18
+
19
+ Args:
20
+ tenant_id: Tenant identifier.
21
+ product: Product name (e.g. "sample-product").
22
+ modules: Map of module name to {"image": "<ecr-uri>:<tag>"}.
23
+ Example: {"extractor": {"image": "123.ecr/extractor:latest"}}
24
+
25
+ Returns:
26
+ {"product": "...", "modules": {"extractor": {"status": "registered"}, ...}}
27
+ """
28
+ container = get_container()
29
+ if container.db_session_factory is None:
30
+ raise ServiceUnavailableError("Database not configured")
31
+
32
+ async with container.db_session_factory() as session:
33
+ repo = ModuleRegistryRepository(session)
34
+ result_modules: dict[str, dict[str, str]] = {}
35
+
36
+ for module_name, module_def in modules.items():
37
+ image = module_def.get("image", "")
38
+ if not image:
39
+ result_modules[module_name] = {
40
+ "status": "error",
41
+ "detail": "missing image",
42
+ }
43
+ continue
44
+ await repo.upsert(tenant_id, product, module_name, image)
45
+ result_modules[module_name] = {"status": "registered"}
46
+
47
+ return {"product": product, "modules": result_modules}
48
+
49
+
50
+ @mcp.tool()
51
+ @handle_mcp_errors
52
+ async def modules_list(
53
+ tenant_id: str,
54
+ product: str,
55
+ ) -> dict[str, Any]:
56
+ """List registered modules for a product.
57
+
58
+ Args:
59
+ tenant_id: Tenant identifier.
60
+ product: Product name.
61
+
62
+ Returns:
63
+ {"product": "...", "modules": [{"name": "...", "image": "..."}, ...]}
64
+ """
65
+ container = get_container()
66
+ if container.db_session_factory is None:
67
+ raise ServiceUnavailableError("Database not configured")
68
+
69
+ async with container.db_session_factory() as session:
70
+ repo = ModuleRegistryRepository(session)
71
+ entries = await repo.list_by_product(tenant_id, product)
72
+ return {
73
+ "product": product,
74
+ "modules": [
75
+ {
76
+ "name": e.module_name,
77
+ "image": e.image,
78
+ "updated_at": e.updated_at.isoformat() if e.updated_at else None,
79
+ }
80
+ for e in entries
81
+ ],
82
+ }
83
+
84
+
85
+ @mcp.tool()
86
+ @handle_mcp_errors
87
+ async def modules_delete(
88
+ tenant_id: str,
89
+ product: str,
90
+ module_name: str,
91
+ ) -> dict[str, Any]:
92
+ """Delete a module registration.
93
+
94
+ Args:
95
+ tenant_id: Tenant identifier.
96
+ product: Product name.
97
+ module_name: Module to delete.
98
+
99
+ Returns:
100
+ {"deleted": true, "product": "...", "module_name": "..."}
101
+ """
102
+ container = get_container()
103
+ if container.db_session_factory is None:
104
+ raise ServiceUnavailableError("Database not configured")
105
+
106
+ async with container.db_session_factory() as session:
107
+ repo = ModuleRegistryRepository(session)
108
+ deleted = await repo.delete(tenant_id, product, module_name)
109
+ if not deleted:
110
+ raise NotFoundError(
111
+ f"Module '{module_name}' not found for product '{product}'",
112
+ product=product,
113
+ module_name=module_name,
114
+ )
115
+ return {"deleted": True, "product": product, "module_name": module_name}