geochat-kernel 1.0.0__tar.gz
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.
- geochat_kernel-1.0.0/LICENSE +21 -0
- geochat_kernel-1.0.0/PKG-INFO +34 -0
- geochat_kernel-1.0.0/README.md +5 -0
- geochat_kernel-1.0.0/geochat_kernel/__init__.py +32 -0
- geochat_kernel-1.0.0/geochat_kernel/bootstrap/__init__.py +16 -0
- geochat_kernel-1.0.0/geochat_kernel/bootstrap/plugin_loader.py +396 -0
- geochat_kernel-1.0.0/geochat_kernel/contracts/__init__.py +50 -0
- geochat_kernel-1.0.0/geochat_kernel/contracts/artifact_builder.py +44 -0
- geochat_kernel-1.0.0/geochat_kernel/contracts/cache.py +54 -0
- geochat_kernel-1.0.0/geochat_kernel/contracts/geodata_provider.py +35 -0
- geochat_kernel-1.0.0/geochat_kernel/contracts/hooks.py +30 -0
- geochat_kernel-1.0.0/geochat_kernel/contracts/job_manager.py +92 -0
- geochat_kernel-1.0.0/geochat_kernel/contracts/llm_provider.py +45 -0
- geochat_kernel-1.0.0/geochat_kernel/contracts/parse_stage.py +44 -0
- geochat_kernel-1.0.0/geochat_kernel/contracts/plan_optimizer.py +44 -0
- geochat_kernel-1.0.0/geochat_kernel/contracts/planner.py +46 -0
- geochat_kernel-1.0.0/geochat_kernel/contracts/plugin.py +73 -0
- geochat_kernel-1.0.0/geochat_kernel/contracts/query_parser.py +45 -0
- geochat_kernel-1.0.0/geochat_kernel/contracts/ranker.py +46 -0
- geochat_kernel-1.0.0/geochat_kernel/contracts/response_composer.py +53 -0
- geochat_kernel-1.0.0/geochat_kernel/contracts/result_fusion.py +50 -0
- geochat_kernel-1.0.0/geochat_kernel/contracts/router.py +101 -0
- geochat_kernel-1.0.0/geochat_kernel/contracts/semantic_enricher.py +42 -0
- geochat_kernel-1.0.0/geochat_kernel/contracts/semantic_registry.py +22 -0
- geochat_kernel-1.0.0/geochat_kernel/contracts/step_handler.py +63 -0
- geochat_kernel-1.0.0/geochat_kernel/contracts/tool.py +41 -0
- geochat_kernel-1.0.0/geochat_kernel/errors/__init__.py +52 -0
- geochat_kernel-1.0.0/geochat_kernel/errors/kernel_errors.py +220 -0
- geochat_kernel-1.0.0/geochat_kernel/models/__init__.py +182 -0
- geochat_kernel-1.0.0/geochat_kernel/models/analytics.py +82 -0
- geochat_kernel-1.0.0/geochat_kernel/models/artifact.py +42 -0
- geochat_kernel-1.0.0/geochat_kernel/models/audit.py +58 -0
- geochat_kernel-1.0.0/geochat_kernel/models/base.py +40 -0
- geochat_kernel-1.0.0/geochat_kernel/models/capability.py +162 -0
- geochat_kernel-1.0.0/geochat_kernel/models/datasource.py +55 -0
- geochat_kernel-1.0.0/geochat_kernel/models/entity.py +40 -0
- geochat_kernel-1.0.0/geochat_kernel/models/error_info.py +22 -0
- geochat_kernel-1.0.0/geochat_kernel/models/execution_artifact.py +123 -0
- geochat_kernel-1.0.0/geochat_kernel/models/geo_feature.py +172 -0
- geochat_kernel-1.0.0/geochat_kernel/models/geo_geometry.py +66 -0
- geochat_kernel-1.0.0/geochat_kernel/models/geo_response.py +310 -0
- geochat_kernel-1.0.0/geochat_kernel/models/interpretation.py +55 -0
- geochat_kernel-1.0.0/geochat_kernel/models/job.py +167 -0
- geochat_kernel-1.0.0/geochat_kernel/models/manifest.py +57 -0
- geochat_kernel-1.0.0/geochat_kernel/models/map_layer.py +58 -0
- geochat_kernel-1.0.0/geochat_kernel/models/query_ir.py +170 -0
- geochat_kernel-1.0.0/geochat_kernel/models/query_plan.py +140 -0
- geochat_kernel-1.0.0/geochat_kernel/models/raster.py +78 -0
- geochat_kernel-1.0.0/geochat_kernel/models/route_decision.py +185 -0
- geochat_kernel-1.0.0/geochat_kernel/models/spatial_relation.py +28 -0
- geochat_kernel-1.0.0/geochat_kernel/models/statistics.py +119 -0
- geochat_kernel-1.0.0/geochat_kernel/models/tool_result.py +69 -0
- geochat_kernel-1.0.0/geochat_kernel/models/trace.py +204 -0
- geochat_kernel-1.0.0/geochat_kernel/models/vocabulary.py +238 -0
- geochat_kernel-1.0.0/geochat_kernel/py.typed +0 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/__init__.py +54 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/artifact_builder_registry.py +38 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/base_registry.py +79 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/cache_registry.py +33 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/capability_registry.py +92 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/composer_registry.py +62 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/fusion_registry.py +65 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/language_registry.py +50 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/llm_registry.py +33 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/ordered_registry.py +50 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/parse_stage_registry.py +33 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/plan_optimizer_registry.py +38 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/planner_registry.py +66 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/plugin_registry.py +87 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/provider_registry.py +38 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/query_parser_registry.py +66 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/ranker_registry.py +62 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/router_registry.py +72 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/semantic_enricher_registry.py +33 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/semantic_type_registry.py +58 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/step_handler_registry.py +67 -0
- geochat_kernel-1.0.0/geochat_kernel/registries/tool_registry.py +20 -0
- geochat_kernel-1.0.0/geochat_kernel/runtime/__init__.py +21 -0
- geochat_kernel-1.0.0/geochat_kernel/runtime/app_container.py +126 -0
- geochat_kernel-1.0.0/geochat_kernel/runtime/error_boundary.py +50 -0
- geochat_kernel-1.0.0/geochat_kernel/runtime/execution_context.py +113 -0
- geochat_kernel-1.0.0/geochat_kernel/runtime/execution_engine.py +115 -0
- geochat_kernel-1.0.0/geochat_kernel/runtime/hook_manager.py +41 -0
- geochat_kernel-1.0.0/geochat_kernel/runtime/plan_executor.py +219 -0
- geochat_kernel-1.0.0/geochat_kernel/runtime/query_pipeline.py +558 -0
- geochat_kernel-1.0.0/geochat_kernel/runtime/router/__init__.py +6 -0
- geochat_kernel-1.0.0/geochat_kernel/runtime/router/keyword_router.py +197 -0
- geochat_kernel-1.0.0/geochat_kernel/runtime/routers/__init__.py +6 -0
- geochat_kernel-1.0.0/geochat_kernel/runtime/routers/keyword_router.py +178 -0
- geochat_kernel-1.0.0/geochat_kernel/runtime/stats_collector.py +253 -0
- geochat_kernel-1.0.0/geochat_kernel/runtime/trace_recorder.py +67 -0
- geochat_kernel-1.0.0/geochat_kernel.egg-info/PKG-INFO +34 -0
- geochat_kernel-1.0.0/geochat_kernel.egg-info/SOURCES.txt +96 -0
- geochat_kernel-1.0.0/geochat_kernel.egg-info/dependency_links.txt +1 -0
- geochat_kernel-1.0.0/geochat_kernel.egg-info/requires.txt +5 -0
- geochat_kernel-1.0.0/geochat_kernel.egg-info/top_level.txt +1 -0
- geochat_kernel-1.0.0/pyproject.toml +47 -0
- geochat_kernel-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Araz Shah
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: geochat-kernel
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Enterprise Orchestration Engine for GeoChat Spatial AI
|
|
5
|
+
Author: Araz Shah
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/arazshah/geochat-platform
|
|
8
|
+
Project-URL: Repository, https://github.com/arazshah/geochat-platform
|
|
9
|
+
Project-URL: Issues, https://github.com/arazshah/geochat-platform/issues
|
|
10
|
+
Keywords: geospatial,gis,orchestration,spatial-analysis,geoai
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: fastapi>=0.100.0
|
|
24
|
+
Requires-Dist: uvicorn>=0.20.0
|
|
25
|
+
Requires-Dist: networkx>=3.0
|
|
26
|
+
Requires-Dist: httpx>=0.24.0
|
|
27
|
+
Requires-Dist: jinja2>=3.0.0
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# geochat-kernel
|
|
31
|
+
|
|
32
|
+
Orchestration engine of [geochat-platform](https://github.com/arazshah/geochat-platform): contracts, domain models, registries, the cascading router, the DAG plan executor and the plugin loader.
|
|
33
|
+
|
|
34
|
+
Plugins are usually written with [`geochat-sdk`](../geochat-sdk). See the [main README](../README.md) for the query flow and a runnable example.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# geochat-kernel
|
|
2
|
+
|
|
3
|
+
Orchestration engine of [geochat-platform](https://github.com/arazshah/geochat-platform): contracts, domain models, registries, the cascading router, the DAG plan executor and the plugin loader.
|
|
4
|
+
|
|
5
|
+
Plugins are usually written with [`geochat-sdk`](../geochat-sdk). See the [main README](../README.md) for the query flow and a runnable example.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# geochat_kernel/__init__.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
# Must match [project].version in ../pyproject.toml - that file is the
|
|
5
|
+
# source of truth for what PyPI publishes, and a mismatch here makes every
|
|
6
|
+
# installation report the wrong runtime version to consumers.
|
|
7
|
+
__version__ = "1.0.0"
|
|
8
|
+
|
|
9
|
+
from geochat_kernel.bootstrap import (
|
|
10
|
+
PluginLoadFailure,
|
|
11
|
+
PluginLoader,
|
|
12
|
+
PluginLoadResult,
|
|
13
|
+
load_plugins_from_folder,
|
|
14
|
+
)
|
|
15
|
+
from geochat_kernel.runtime import (
|
|
16
|
+
ExecutionContext,
|
|
17
|
+
KernelAppContainer,
|
|
18
|
+
QueryPipeline,
|
|
19
|
+
UserLocation,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"__version__",
|
|
24
|
+
"PluginLoadFailure",
|
|
25
|
+
"PluginLoader",
|
|
26
|
+
"PluginLoadResult",
|
|
27
|
+
"load_plugins_from_folder",
|
|
28
|
+
"ExecutionContext",
|
|
29
|
+
"KernelAppContainer",
|
|
30
|
+
"QueryPipeline",
|
|
31
|
+
"UserLocation",
|
|
32
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# geochat_kernel/bootstrap/__init__.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from geochat_kernel.bootstrap.plugin_loader import (
|
|
5
|
+
PluginLoadFailure,
|
|
6
|
+
PluginLoader,
|
|
7
|
+
PluginLoadResult,
|
|
8
|
+
load_plugins_from_folder,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"PluginLoadFailure",
|
|
13
|
+
"PluginLoader",
|
|
14
|
+
"PluginLoadResult",
|
|
15
|
+
"load_plugins_from_folder",
|
|
16
|
+
]
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
# geochat_kernel/bootstrap/plugin_loader.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import importlib.util
|
|
6
|
+
import inspect
|
|
7
|
+
import sys
|
|
8
|
+
import types
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import ModuleType
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import Field
|
|
14
|
+
|
|
15
|
+
from geochat_kernel.contracts.plugin import BasePlugin
|
|
16
|
+
from geochat_kernel.errors import KernelPluginError
|
|
17
|
+
from geochat_kernel.models.base import KernelModel
|
|
18
|
+
from geochat_kernel.runtime.app_container import KernelAppContainer
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_DYNAMIC_PLUGIN_PACKAGE = "_geochat_dynamic_plugins"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PluginLoadFailure(KernelModel):
|
|
25
|
+
"""Information about a plugin/module that failed during discovery/import."""
|
|
26
|
+
|
|
27
|
+
source: str
|
|
28
|
+
stage: str
|
|
29
|
+
error_type: str
|
|
30
|
+
message: str
|
|
31
|
+
details: dict[str, Any] = Field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PluginLoadResult(KernelModel):
|
|
35
|
+
"""
|
|
36
|
+
Result of plugin auto-discovery.
|
|
37
|
+
|
|
38
|
+
This is returned even if some plugins fail to load, unless strict=True.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
plugins_folder: str
|
|
42
|
+
discovered_sources: list[str] = Field(default_factory=list)
|
|
43
|
+
loaded_plugin_ids: list[str] = Field(default_factory=list)
|
|
44
|
+
failures: list[PluginLoadFailure] = Field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def ok(self) -> bool:
|
|
48
|
+
return len(self.failures) == 0
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def loaded_count(self) -> int:
|
|
52
|
+
return len(self.loaded_plugin_ids)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def failure_count(self) -> int:
|
|
56
|
+
return len(self.failures)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PluginLoader:
|
|
60
|
+
"""
|
|
61
|
+
Auto-discovers trusted Python plugins from a folder.
|
|
62
|
+
|
|
63
|
+
Supported plugin shapes inside `plugins/`:
|
|
64
|
+
|
|
65
|
+
1) Single-file plugin:
|
|
66
|
+
plugins/my_plugin.py
|
|
67
|
+
|
|
68
|
+
2) Package plugin:
|
|
69
|
+
plugins/my_plugin/__init__.py
|
|
70
|
+
|
|
71
|
+
A plugin module/package can expose plugins using any of these patterns:
|
|
72
|
+
|
|
73
|
+
A) PLUGIN = MyPlugin()
|
|
74
|
+
B) PLUGINS = [PluginA(), PluginB()]
|
|
75
|
+
C) def get_plugin() -> BasePlugin
|
|
76
|
+
D) def get_plugins() -> list[BasePlugin]
|
|
77
|
+
E) zero-argument subclasses of BasePlugin defined in the module
|
|
78
|
+
|
|
79
|
+
Notes:
|
|
80
|
+
- Plugins are trusted Python code in MVP.
|
|
81
|
+
- Heavy dependencies like shapely/rasterio/numpy can exist inside plugins.
|
|
82
|
+
The kernel itself does not import those dependencies directly.
|
|
83
|
+
- Dependency order is NOT handled at import-time; it is handled later by
|
|
84
|
+
KernelAppContainer.initialize_plugins() using PluginManifest.dependencies.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
container: KernelAppContainer,
|
|
90
|
+
*,
|
|
91
|
+
plugins_folder: str | Path = "plugins",
|
|
92
|
+
strict: bool = False,
|
|
93
|
+
) -> None:
|
|
94
|
+
self.container = container
|
|
95
|
+
self.plugins_folder = Path(plugins_folder)
|
|
96
|
+
self.strict = strict
|
|
97
|
+
self._ensure_dynamic_package()
|
|
98
|
+
|
|
99
|
+
async def discover_register_initialize(self) -> PluginLoadResult:
|
|
100
|
+
"""
|
|
101
|
+
Discover plugins, register them in the container, then initialize them.
|
|
102
|
+
|
|
103
|
+
This is the most convenient method for app startup.
|
|
104
|
+
"""
|
|
105
|
+
result = self.discover_and_register()
|
|
106
|
+
if result.failures and self.strict:
|
|
107
|
+
raise KernelPluginError(
|
|
108
|
+
"Plugin discovery failed.",
|
|
109
|
+
details=result.to_dict(),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
await self.container.initialize_plugins()
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
def discover_and_register(self) -> PluginLoadResult:
|
|
116
|
+
"""
|
|
117
|
+
Discover plugins and register plugin objects in KernelAppContainer.
|
|
118
|
+
|
|
119
|
+
Does not call initialize_plugins(); caller can do that manually.
|
|
120
|
+
"""
|
|
121
|
+
result = PluginLoadResult(plugins_folder=str(self.plugins_folder))
|
|
122
|
+
|
|
123
|
+
if not self.plugins_folder.exists():
|
|
124
|
+
if self.strict:
|
|
125
|
+
raise KernelPluginError(
|
|
126
|
+
f"Plugins folder does not exist: {self.plugins_folder}",
|
|
127
|
+
details={"plugins_folder": str(self.plugins_folder)},
|
|
128
|
+
)
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
if not self.plugins_folder.is_dir():
|
|
132
|
+
raise KernelPluginError(
|
|
133
|
+
f"Plugins path is not a directory: {self.plugins_folder}",
|
|
134
|
+
details={"plugins_folder": str(self.plugins_folder)},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
for source in self._iter_plugin_sources(self.plugins_folder):
|
|
138
|
+
result.discovered_sources.append(str(source))
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
module = self._import_source(source)
|
|
142
|
+
plugins = self._extract_plugins(module)
|
|
143
|
+
|
|
144
|
+
if not plugins:
|
|
145
|
+
result.failures.append(
|
|
146
|
+
PluginLoadFailure(
|
|
147
|
+
source=str(source),
|
|
148
|
+
stage="extract",
|
|
149
|
+
error_type="NoPluginFound",
|
|
150
|
+
message=(
|
|
151
|
+
"No BasePlugin instance found. Expected PLUGIN, "
|
|
152
|
+
"PLUGINS, get_plugin(), get_plugins(), or a "
|
|
153
|
+
"zero-argument BasePlugin subclass."
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
for plugin in plugins:
|
|
160
|
+
self.container.register_plugin(plugin)
|
|
161
|
+
result.loaded_plugin_ids.append(plugin.id)
|
|
162
|
+
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
failure = PluginLoadFailure(
|
|
165
|
+
source=str(source),
|
|
166
|
+
stage="discover_register",
|
|
167
|
+
error_type=type(exc).__name__,
|
|
168
|
+
message=str(exc),
|
|
169
|
+
)
|
|
170
|
+
result.failures.append(failure)
|
|
171
|
+
|
|
172
|
+
if self.strict:
|
|
173
|
+
raise KernelPluginError(
|
|
174
|
+
f"Failed to load plugin source: {source}",
|
|
175
|
+
details=failure.to_dict(),
|
|
176
|
+
cause=exc,
|
|
177
|
+
) from exc
|
|
178
|
+
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------ #
|
|
182
|
+
# Discovery #
|
|
183
|
+
# ------------------------------------------------------------------ #
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _iter_plugin_sources(folder: Path) -> list[Path]:
|
|
187
|
+
"""
|
|
188
|
+
Return direct plugin sources from a plugins folder.
|
|
189
|
+
|
|
190
|
+
Direct children only:
|
|
191
|
+
- *.py files except __init__.py and files starting with _
|
|
192
|
+
- directories containing __init__.py except directories starting with _
|
|
193
|
+
"""
|
|
194
|
+
sources: list[Path] = []
|
|
195
|
+
|
|
196
|
+
for child in sorted(folder.iterdir(), key=lambda p: p.name):
|
|
197
|
+
if child.name.startswith("_"):
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
if child.is_file():
|
|
201
|
+
if child.suffix == ".py" and child.name != "__init__.py":
|
|
202
|
+
sources.append(child)
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
if child.is_dir():
|
|
206
|
+
init_py = child / "__init__.py"
|
|
207
|
+
if init_py.exists() and init_py.is_file():
|
|
208
|
+
sources.append(child)
|
|
209
|
+
|
|
210
|
+
return sources
|
|
211
|
+
|
|
212
|
+
# ------------------------------------------------------------------ #
|
|
213
|
+
# Import #
|
|
214
|
+
# ------------------------------------------------------------------ #
|
|
215
|
+
|
|
216
|
+
@staticmethod
|
|
217
|
+
def _ensure_dynamic_package() -> None:
|
|
218
|
+
"""
|
|
219
|
+
Ensure the parent namespace package exists in sys.modules.
|
|
220
|
+
|
|
221
|
+
This allows loading plugin packages as:
|
|
222
|
+
_geochat_dynamic_plugins.<plugin_name_hash>
|
|
223
|
+
and makes relative imports inside plugin packages work.
|
|
224
|
+
"""
|
|
225
|
+
if _DYNAMIC_PLUGIN_PACKAGE in sys.modules:
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
pkg = types.ModuleType(_DYNAMIC_PLUGIN_PACKAGE)
|
|
229
|
+
pkg.__path__ = [] # type: ignore[attr-defined]
|
|
230
|
+
sys.modules[_DYNAMIC_PLUGIN_PACKAGE] = pkg
|
|
231
|
+
|
|
232
|
+
def _import_source(self, source: Path) -> ModuleType:
|
|
233
|
+
if source.is_dir():
|
|
234
|
+
return self._import_package(source)
|
|
235
|
+
return self._import_file(source)
|
|
236
|
+
|
|
237
|
+
def _import_file(self, file_path: Path) -> ModuleType:
|
|
238
|
+
module_name = self._module_name_for_source(file_path)
|
|
239
|
+
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
|
240
|
+
|
|
241
|
+
if spec is None or spec.loader is None:
|
|
242
|
+
raise KernelPluginError(
|
|
243
|
+
f"Could not create import spec for plugin file: {file_path}",
|
|
244
|
+
details={"file_path": str(file_path)},
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
module = importlib.util.module_from_spec(spec)
|
|
248
|
+
sys.modules[module_name] = module
|
|
249
|
+
spec.loader.exec_module(module)
|
|
250
|
+
return module
|
|
251
|
+
|
|
252
|
+
def _import_package(self, package_dir: Path) -> ModuleType:
|
|
253
|
+
init_py = package_dir / "__init__.py"
|
|
254
|
+
module_name = self._module_name_for_source(package_dir)
|
|
255
|
+
|
|
256
|
+
spec = importlib.util.spec_from_file_location(
|
|
257
|
+
module_name,
|
|
258
|
+
init_py,
|
|
259
|
+
submodule_search_locations=[str(package_dir)],
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if spec is None or spec.loader is None:
|
|
263
|
+
raise KernelPluginError(
|
|
264
|
+
f"Could not create import spec for plugin package: {package_dir}",
|
|
265
|
+
details={"package_dir": str(package_dir)},
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
module = importlib.util.module_from_spec(spec)
|
|
269
|
+
sys.modules[module_name] = module
|
|
270
|
+
spec.loader.exec_module(module)
|
|
271
|
+
return module
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def _module_name_for_source(source: Path) -> str:
|
|
275
|
+
resolved = str(source.resolve())
|
|
276
|
+
digest = hashlib.sha1(resolved.encode("utf-8")).hexdigest()[:12]
|
|
277
|
+
safe_name = source.stem if source.is_file() else source.name
|
|
278
|
+
safe_name = "".join(ch if ch.isalnum() or ch == "_" else "_" for ch in safe_name)
|
|
279
|
+
return f"{_DYNAMIC_PLUGIN_PACKAGE}.{safe_name}_{digest}"
|
|
280
|
+
|
|
281
|
+
# ------------------------------------------------------------------ #
|
|
282
|
+
# Plugin extraction #
|
|
283
|
+
# ------------------------------------------------------------------ #
|
|
284
|
+
|
|
285
|
+
def _extract_plugins(self, module: ModuleType) -> list[BasePlugin]:
|
|
286
|
+
plugins: list[BasePlugin] = []
|
|
287
|
+
|
|
288
|
+
# A) PLUGIN = MyPlugin()
|
|
289
|
+
if hasattr(module, "PLUGIN"):
|
|
290
|
+
plugins.extend(self._coerce_plugin_value(getattr(module, "PLUGIN")))
|
|
291
|
+
|
|
292
|
+
# B) PLUGINS = [PluginA(), PluginB()]
|
|
293
|
+
if hasattr(module, "PLUGINS"):
|
|
294
|
+
plugins.extend(self._coerce_plugin_value(getattr(module, "PLUGINS")))
|
|
295
|
+
|
|
296
|
+
# C) get_plugin()
|
|
297
|
+
get_plugin = getattr(module, "get_plugin", None)
|
|
298
|
+
if callable(get_plugin):
|
|
299
|
+
plugins.extend(self._coerce_plugin_value(get_plugin()))
|
|
300
|
+
|
|
301
|
+
# D) get_plugins()
|
|
302
|
+
get_plugins = getattr(module, "get_plugins", None)
|
|
303
|
+
if callable(get_plugins):
|
|
304
|
+
plugins.extend(self._coerce_plugin_value(get_plugins()))
|
|
305
|
+
|
|
306
|
+
# E) zero-argument subclasses defined in this exact module
|
|
307
|
+
# Avoid duplicates by plugin id.
|
|
308
|
+
existing_ids = {p.id for p in plugins}
|
|
309
|
+
|
|
310
|
+
for _, obj in inspect.getmembers(module, inspect.isclass):
|
|
311
|
+
if obj is BasePlugin:
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
if not issubclass(obj, BasePlugin):
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
# only instantiate classes defined in this module, not imported ones
|
|
318
|
+
if obj.__module__ != module.__name__:
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
signature = inspect.signature(obj)
|
|
323
|
+
required_params = [
|
|
324
|
+
p
|
|
325
|
+
for p in signature.parameters.values()
|
|
326
|
+
if p.default is inspect.Parameter.empty
|
|
327
|
+
and p.kind
|
|
328
|
+
in (
|
|
329
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
330
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
331
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
332
|
+
)
|
|
333
|
+
]
|
|
334
|
+
|
|
335
|
+
if required_params:
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
instance = obj()
|
|
339
|
+
if instance.id not in existing_ids:
|
|
340
|
+
plugins.append(instance)
|
|
341
|
+
existing_ids.add(instance.id)
|
|
342
|
+
|
|
343
|
+
except TypeError:
|
|
344
|
+
continue
|
|
345
|
+
|
|
346
|
+
return plugins
|
|
347
|
+
|
|
348
|
+
@staticmethod
|
|
349
|
+
def _coerce_plugin_value(value: Any) -> list[BasePlugin]:
|
|
350
|
+
if value is None:
|
|
351
|
+
return []
|
|
352
|
+
|
|
353
|
+
if isinstance(value, BasePlugin):
|
|
354
|
+
return [value]
|
|
355
|
+
|
|
356
|
+
if isinstance(value, (list, tuple, set)):
|
|
357
|
+
plugins: list[BasePlugin] = []
|
|
358
|
+
for item in value:
|
|
359
|
+
if not isinstance(item, BasePlugin):
|
|
360
|
+
raise KernelPluginError(
|
|
361
|
+
"Plugin collection contains a non-BasePlugin item.",
|
|
362
|
+
details={"item_type": type(item).__name__},
|
|
363
|
+
)
|
|
364
|
+
plugins.append(item)
|
|
365
|
+
return plugins
|
|
366
|
+
|
|
367
|
+
raise KernelPluginError(
|
|
368
|
+
"Invalid plugin export value.",
|
|
369
|
+
details={"value_type": type(value).__name__},
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
async def load_plugins_from_folder(
|
|
374
|
+
container: KernelAppContainer,
|
|
375
|
+
plugins_folder: str | Path = "plugins",
|
|
376
|
+
*,
|
|
377
|
+
strict: bool = False,
|
|
378
|
+
initialize: bool = True,
|
|
379
|
+
) -> PluginLoadResult:
|
|
380
|
+
"""
|
|
381
|
+
Convenience function for app startup.
|
|
382
|
+
|
|
383
|
+
Example:
|
|
384
|
+
container = KernelAppContainer()
|
|
385
|
+
result = await load_plugins_from_folder(container, "plugins")
|
|
386
|
+
"""
|
|
387
|
+
loader = PluginLoader(
|
|
388
|
+
container,
|
|
389
|
+
plugins_folder=plugins_folder,
|
|
390
|
+
strict=strict,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
if initialize:
|
|
394
|
+
return await loader.discover_register_initialize()
|
|
395
|
+
|
|
396
|
+
return loader.discover_and_register()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# geochat_kernel/contracts/__init__.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from geochat_kernel.contracts.artifact_builder import BaseArtifactBuilder
|
|
5
|
+
from geochat_kernel.contracts.cache import BaseCache
|
|
6
|
+
from geochat_kernel.contracts.geodata_provider import BaseGeodataProvider
|
|
7
|
+
from geochat_kernel.contracts.hooks import HookContext
|
|
8
|
+
from geochat_kernel.contracts.job_manager import BaseJobManager
|
|
9
|
+
from geochat_kernel.contracts.router import (
|
|
10
|
+
BaseRouter,
|
|
11
|
+
RouterConfig,
|
|
12
|
+
RoutingRequest,
|
|
13
|
+
)
|
|
14
|
+
from geochat_kernel.contracts.llm_provider import BaseLLMProvider
|
|
15
|
+
from geochat_kernel.contracts.parse_stage import BaseParseStage
|
|
16
|
+
from geochat_kernel.contracts.plan_optimizer import BasePlanOptimizer
|
|
17
|
+
from geochat_kernel.contracts.planner import BasePlanner
|
|
18
|
+
from geochat_kernel.contracts.plugin import BasePlugin
|
|
19
|
+
from geochat_kernel.contracts.query_parser import BaseQueryParser
|
|
20
|
+
from geochat_kernel.contracts.ranker import BaseRanker
|
|
21
|
+
from geochat_kernel.contracts.response_composer import BaseResponseComposer
|
|
22
|
+
from geochat_kernel.contracts.result_fusion import BaseResultFusion
|
|
23
|
+
from geochat_kernel.contracts.semantic_enricher import BaseSemanticEnricher
|
|
24
|
+
from geochat_kernel.contracts.semantic_registry import BaseSemanticRegistry
|
|
25
|
+
from geochat_kernel.contracts.step_handler import BaseStepHandler
|
|
26
|
+
from geochat_kernel.contracts.tool import BaseTool
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"BaseArtifactBuilder",
|
|
30
|
+
"BaseCache",
|
|
31
|
+
"BaseGeodataProvider",
|
|
32
|
+
"HookContext",
|
|
33
|
+
"BaseJobManager",
|
|
34
|
+
"BaseRouter",
|
|
35
|
+
"RouterConfig",
|
|
36
|
+
"RoutingRequest",
|
|
37
|
+
"BaseLLMProvider",
|
|
38
|
+
"BaseParseStage",
|
|
39
|
+
"BasePlanOptimizer",
|
|
40
|
+
"BasePlanner",
|
|
41
|
+
"BasePlugin",
|
|
42
|
+
"BaseQueryParser",
|
|
43
|
+
"BaseRanker",
|
|
44
|
+
"BaseResponseComposer",
|
|
45
|
+
"BaseResultFusion",
|
|
46
|
+
"BaseSemanticEnricher",
|
|
47
|
+
"BaseSemanticRegistry",
|
|
48
|
+
"BaseStepHandler",
|
|
49
|
+
"BaseTool",
|
|
50
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# geochat_kernel/contracts/artifact_builder.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from geochat_kernel.models.geo_response import GeoResponse
|
|
8
|
+
from geochat_kernel.models.query_ir import QueryIR
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from geochat_kernel.runtime.execution_context import ExecutionContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseArtifactBuilder(ABC):
|
|
15
|
+
"""
|
|
16
|
+
Adds/derives user-facing artifacts from a GeoResponse.
|
|
17
|
+
|
|
18
|
+
Examples:
|
|
19
|
+
- create a MapLayer from features
|
|
20
|
+
- create a heatmap layer
|
|
21
|
+
- create a chart artifact from analytics
|
|
22
|
+
- attach a downloadable report artifact
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def name(self) -> str:
|
|
28
|
+
"""Unique artifact builder name."""
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def priority(self) -> int:
|
|
32
|
+
return 100
|
|
33
|
+
|
|
34
|
+
def can_build(self, response: GeoResponse, query_ir: QueryIR) -> bool:
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
async def build(
|
|
39
|
+
self,
|
|
40
|
+
response: GeoResponse,
|
|
41
|
+
query_ir: QueryIR,
|
|
42
|
+
context: "ExecutionContext",
|
|
43
|
+
) -> GeoResponse:
|
|
44
|
+
"""Return response enriched with artifacts/layers."""
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# geochat_kernel/contracts/cache.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseCache(ABC):
|
|
9
|
+
"""
|
|
10
|
+
Cache abstraction only.
|
|
11
|
+
|
|
12
|
+
The kernel defines the contract; implementations live outside the kernel:
|
|
13
|
+
memory, Redis, file cache, database, object storage, etc.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def name(self) -> str:
|
|
19
|
+
"""Unique cache implementation name."""
|
|
20
|
+
|
|
21
|
+
async def initialize(self) -> None:
|
|
22
|
+
"""Optional initialization."""
|
|
23
|
+
|
|
24
|
+
async def shutdown(self) -> None:
|
|
25
|
+
"""Optional shutdown."""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
async def get(self, key: str, *, namespace: str | None = None) -> Any | None:
|
|
29
|
+
"""Return cached value or None."""
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
async def set(
|
|
33
|
+
self,
|
|
34
|
+
key: str,
|
|
35
|
+
value: Any,
|
|
36
|
+
*,
|
|
37
|
+
namespace: str | None = None,
|
|
38
|
+
ttl_s: float | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Store value."""
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
async def delete(self, key: str, *, namespace: str | None = None) -> None:
|
|
44
|
+
"""Delete a cached value."""
|
|
45
|
+
|
|
46
|
+
async def exists(self, key: str, *, namespace: str | None = None) -> bool:
|
|
47
|
+
return await self.get(key, namespace=namespace) is not None
|
|
48
|
+
|
|
49
|
+
async def clear_namespace(self, namespace: str) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Optional bulk clear. Implementations may override.
|
|
52
|
+
|
|
53
|
+
Default is no-op because not all backends can efficiently support it.
|
|
54
|
+
"""
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# geochat_kernel/contracts/geodata_provider.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
|
|
6
|
+
from geochat_kernel.models.datasource import DataSourceDescriptor
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseGeodataProvider(ABC):
|
|
10
|
+
"""
|
|
11
|
+
Data provider abstraction.
|
|
12
|
+
|
|
13
|
+
Providers expose data source metadata and optional lifecycle/health hooks.
|
|
14
|
+
Actual step execution is handled by StepHandlers, which may internally use
|
|
15
|
+
providers, tools, remote services, or anything else.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def name(self) -> str:
|
|
21
|
+
"""Unique provider name."""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def get_descriptor(self) -> DataSourceDescriptor:
|
|
25
|
+
"""Return source descriptor."""
|
|
26
|
+
|
|
27
|
+
async def initialize(self) -> None:
|
|
28
|
+
"""Optional provider initialization."""
|
|
29
|
+
|
|
30
|
+
async def shutdown(self) -> None:
|
|
31
|
+
"""Optional provider shutdown."""
|
|
32
|
+
|
|
33
|
+
async def healthcheck(self) -> bool:
|
|
34
|
+
"""Optional healthcheck."""
|
|
35
|
+
return True
|