decision-layer 0.1.0a1__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.
- decision_layer/__init__.py +1 -0
- decision_layer/__main__.py +4 -0
- decision_layer/api/__init__.py +0 -0
- decision_layer/api/app.py +94 -0
- decision_layer/api/dependencies.py +113 -0
- decision_layer/api/methods.py +124 -0
- decision_layer/api/recipes.py +128 -0
- decision_layer/api/runs.py +173 -0
- decision_layer/api/semantic.py +51 -0
- decision_layer/api/sources.py +130 -0
- decision_layer/auth.py +63 -0
- decision_layer/cli.py +178 -0
- decision_layer/core/__init__.py +0 -0
- decision_layer/core/errors.py +54 -0
- decision_layer/core/ids.py +95 -0
- decision_layer/core/interpretation.py +11 -0
- decision_layer/core/models.py +750 -0
- decision_layer/core/periods.py +113 -0
- decision_layer/dev.py +182 -0
- decision_layer/env.py +25 -0
- decision_layer/guides/authoring.md +138 -0
- decision_layer/guides/compatibility/drilldown-1.0.1.md +25 -0
- decision_layer/guides/compatibility/trend_query-1.0.0.md +26 -0
- decision_layer/guides/cube-query.md +16 -0
- decision_layer/guides/methods/breakdown.md +8 -0
- decision_layer/guides/methods/drilldown.md +25 -0
- decision_layer/guides/methods/metric.md +8 -0
- decision_layer/guides/methods/metric_query.md +22 -0
- decision_layer/guides/methods/peer_query.md +29 -0
- decision_layer/guides/methods/peers.md +8 -0
- decision_layer/guides/methods/trend_query.md +24 -0
- decision_layer/i18n/__init__.py +53 -0
- decision_layer/i18n/ko.json +408 -0
- decision_layer/mcp/__init__.py +0 -0
- decision_layer/mcp/server.py +532 -0
- decision_layer/methods/__init__.py +32 -0
- decision_layer/methods/advanced.py +132 -0
- decision_layer/methods/aggregate.py +46 -0
- decision_layer/methods/asset_worker.py +8 -0
- decision_layer/methods/assets.py +220 -0
- decision_layer/methods/authoring.py +138 -0
- decision_layer/methods/base.py +35 -0
- decision_layer/methods/binary_matching.py +99 -0
- decision_layer/methods/calculation.py +176 -0
- decision_layer/methods/cem.py +466 -0
- decision_layer/methods/change.py +212 -0
- decision_layer/methods/common.py +70 -0
- decision_layer/methods/compatibility.py +28 -0
- decision_layer/methods/computation.py +84 -0
- decision_layer/methods/context.py +181 -0
- decision_layer/methods/data.py +150 -0
- decision_layer/methods/dependencies.py +25 -0
- decision_layer/methods/discovery.py +90 -0
- decision_layer/methods/drilldown.py +214 -0
- decision_layer/methods/formatting.py +48 -0
- decision_layer/methods/inputs.py +53 -0
- decision_layer/methods/invocation.py +23 -0
- decision_layer/methods/legacy_advanced.py +99 -0
- decision_layer/methods/loading.py +26 -0
- decision_layer/methods/matching.py +208 -0
- decision_layer/methods/native_matching.py +192 -0
- decision_layer/methods/native_regression.py +227 -0
- decision_layer/methods/output.py +83 -0
- decision_layer/methods/peer_comparison.py +84 -0
- decision_layer/methods/python-style.toml +10 -0
- decision_layer/methods/python.py +491 -0
- decision_layer/methods/registry.py +306 -0
- decision_layer/methods/regression_adjustment.py +93 -0
- decision_layer/methods/series_diagnostics.py +20 -0
- decision_layer/methods/skills.py +90 -0
- decision_layer/methods/source.py +176 -0
- decision_layer/methods/statistics.py +324 -0
- decision_layer/methods/stats.py +67 -0
- decision_layer/methods/time_analysis.py +112 -0
- decision_layer/methods/trend.py +109 -0
- decision_layer/project.py +169 -0
- decision_layer/recipes/__init__.py +0 -0
- decision_layer/recipes/authoring.py +194 -0
- decision_layer/recipes/configuration.py +63 -0
- decision_layer/recipes/from_run.py +158 -0
- decision_layer/recipes/loader.py +265 -0
- decision_layer/recipes/routing.py +107 -0
- decision_layer/runs/__init__.py +0 -0
- decision_layer/runs/engine.py +806 -0
- decision_layer/runs/expressions.py +100 -0
- decision_layer/runs/goals.py +26 -0
- decision_layer/runs/jobs.py +44 -0
- decision_layer/runs/policy.py +29 -0
- decision_layer/runs/proposals.py +24 -0
- decision_layer/runs/remediation.py +198 -0
- decision_layer/runs/skills.py +171 -0
- decision_layer/runs/store.py +179 -0
- decision_layer/semantic/__init__.py +0 -0
- decision_layer/semantic/credentials.py +47 -0
- decision_layer/semantic/provider.py +31 -0
- decision_layer/semantic/providers/__init__.py +0 -0
- decision_layer/semantic/providers/cube/__init__.py +0 -0
- decision_layer/semantic/providers/cube/catalog_mapper.py +121 -0
- decision_layer/semantic/providers/cube/client.py +86 -0
- decision_layer/semantic/providers/cube/compiler.py +88 -0
- decision_layer/semantic/providers/cube/provider.py +124 -0
- decision_layer/semantic/providers/dbt/__init__.py +1 -0
- decision_layer/semantic/providers/dbt/provider.py +296 -0
- decision_layer/service.py +171 -0
- decision_layer/settings.py +64 -0
- decision_layer/sources/__init__.py +1 -0
- decision_layer/sources/config.py +208 -0
- decision_layer/sources/provider.py +71 -0
- decision_layer/sources/store.py +79 -0
- decision_layer/testing.py +95 -0
- decision_layer/validation/__init__.py +0 -0
- decision_layer/validation/builtin.py +101 -0
- decision_layer-0.1.0a1.dist-info/METADATA +322 -0
- decision_layer-0.1.0a1.dist-info/RECORD +117 -0
- decision_layer-0.1.0a1.dist-info/WHEEL +4 -0
- decision_layer-0.1.0a1.dist-info/entry_points.txt +3 -0
- decision_layer-0.1.0a1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0a1"
|
|
File without changes
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""FastAPI assembly and lifecycle. Routes live beside this file by resource."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from fastapi import FastAPI, Request
|
|
5
|
+
from fastapi.responses import JSONResponse
|
|
6
|
+
from .. import __version__
|
|
7
|
+
from ..i18n import negotiate, set_locale
|
|
8
|
+
from ..core.errors import DecisionLayerError
|
|
9
|
+
from ..core.models import CallerInfo
|
|
10
|
+
from ..semantic.provider import SemanticProvider
|
|
11
|
+
from ..sources.config import SourceConfigManager
|
|
12
|
+
from ..sources.provider import ConfiguredSemanticProvider
|
|
13
|
+
from ..sources.store import open_source_store
|
|
14
|
+
from ..recipes.loader import RecipeStore
|
|
15
|
+
from ..runs.engine import RunEngine
|
|
16
|
+
from ..runs.jobs import JobRunner
|
|
17
|
+
from ..runs.store import RunStore, open_store
|
|
18
|
+
from ..settings import Settings
|
|
19
|
+
from .dependencies import Caller
|
|
20
|
+
|
|
21
|
+
def create_app(settings: Settings | None = None, provider: SemanticProvider | None = None,
|
|
22
|
+
store: RunStore | None = None, recipes: RecipeStore | None = None) -> FastAPI:
|
|
23
|
+
settings = settings or Settings()
|
|
24
|
+
settings.validate_deployment()
|
|
25
|
+
source_manager = SourceConfigManager(open_source_store(settings.database_url), settings)
|
|
26
|
+
provider = provider or ConfiguredSemanticProvider(source_manager)
|
|
27
|
+
from ..methods.loading import configured_registry
|
|
28
|
+
registry = configured_registry(settings)
|
|
29
|
+
from ..methods.assets import MethodAssets
|
|
30
|
+
assets = MethodAssets(settings.method_assets_dir, registry)
|
|
31
|
+
recipes = recipes or RecipeStore(settings.recipes_dir, provider.name, provider.instance, registry=registry)
|
|
32
|
+
recipes.registry = registry
|
|
33
|
+
engine = RunEngine(provider, recipes, store or open_store(settings.database_url),
|
|
34
|
+
jobs=JobRunner(settings.job_workers), policy=settings.execution_policy, registry=registry)
|
|
35
|
+
|
|
36
|
+
@asynccontextmanager
|
|
37
|
+
async def lifespan(_app: FastAPI):
|
|
38
|
+
await source_manager.effective(resolve_secret=False)
|
|
39
|
+
await engine.recover()
|
|
40
|
+
yield
|
|
41
|
+
engine.jobs.shutdown()
|
|
42
|
+
|
|
43
|
+
app = FastAPI(title="Decision Layer", version=__version__, lifespan=lifespan)
|
|
44
|
+
|
|
45
|
+
@app.middleware("http")
|
|
46
|
+
async def locale(request: Request, call_next):
|
|
47
|
+
from urllib.parse import urlsplit
|
|
48
|
+
try:
|
|
49
|
+
host = urlsplit("//" + request.headers.get("host", "")).hostname
|
|
50
|
+
origin = request.headers.get("origin")
|
|
51
|
+
parsed_origin = urlsplit(origin) if origin else None
|
|
52
|
+
except ValueError:
|
|
53
|
+
return JSONResponse(status_code=403, content={"error": {"code": "LOCAL_HTTP_ORIGIN_DENIED"}})
|
|
54
|
+
if host not in settings.http_allowed_hosts or (parsed_origin and (
|
|
55
|
+
parsed_origin.scheme not in ("http", "https") or parsed_origin.hostname not in settings.http_allowed_hosts)):
|
|
56
|
+
return JSONResponse(status_code=403, content={"error": {
|
|
57
|
+
"code": "LOCAL_HTTP_ORIGIN_DENIED", "message": "Host or Origin is not allowed for this local deployment."}})
|
|
58
|
+
set_locale(negotiate(request.headers.get("accept-language"), settings.locale))
|
|
59
|
+
response = await call_next(request)
|
|
60
|
+
response.headers["Content-Language"] = negotiate(request.headers.get("accept-language"), settings.locale)
|
|
61
|
+
return response
|
|
62
|
+
app.state.method_assets = assets
|
|
63
|
+
app.state.settings = settings
|
|
64
|
+
app.state.provider = provider
|
|
65
|
+
app.state.source_manager = source_manager
|
|
66
|
+
|
|
67
|
+
@app.exception_handler(DecisionLayerError)
|
|
68
|
+
async def _app_error(_request: Request, exc: DecisionLayerError) -> JSONResponse:
|
|
69
|
+
return JSONResponse(status_code=exc.http_status, content={"error": exc.to_dict()})
|
|
70
|
+
|
|
71
|
+
@app.get("/health")
|
|
72
|
+
async def health() -> dict:
|
|
73
|
+
await source_manager.effective(resolve_secret=False)
|
|
74
|
+
return {"status": "ok", "version": __version__, "provider": provider.name, "instance": provider.instance}
|
|
75
|
+
|
|
76
|
+
@app.get("/me", response_model=CallerInfo)
|
|
77
|
+
async def me(caller: Caller) -> CallerInfo:
|
|
78
|
+
"""The identity runs are stored under for this caller."""
|
|
79
|
+
return caller
|
|
80
|
+
|
|
81
|
+
from .sources import create_router as sources_router
|
|
82
|
+
app.include_router(sources_router(settings, provider, source_manager))
|
|
83
|
+
from .semantic import create_router as semantic_router
|
|
84
|
+
app.include_router(semantic_router(provider, engine))
|
|
85
|
+
from .methods import create_router as methods_router
|
|
86
|
+
app.include_router(methods_router(settings, engine))
|
|
87
|
+
from .recipes import create_router as recipes_router
|
|
88
|
+
app.include_router(recipes_router(settings, provider, recipes, engine))
|
|
89
|
+
from .runs import create_router as runs_router
|
|
90
|
+
app.include_router(runs_router(settings, provider, recipes, engine))
|
|
91
|
+
return app
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
app = create_app()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Authentication and shared HTTP response helpers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from typing import Annotated, Literal
|
|
4
|
+
from fastapi import Depends, Header, Request
|
|
5
|
+
from fastapi.responses import JSONResponse
|
|
6
|
+
from ..auth import credentials, identify
|
|
7
|
+
from ..i18n import _
|
|
8
|
+
from ..core.errors import DecisionLayerError
|
|
9
|
+
from ..core.models import CallerInfo, MethodManifest, Run
|
|
10
|
+
from ..semantic.credentials import AnonymousServiceCredentials, RequestCredentials, ServiceCredentials
|
|
11
|
+
from ..sources.config import EffectiveSource
|
|
12
|
+
from ..sources.provider import ConfiguredSemanticProvider
|
|
13
|
+
from ..settings import Settings
|
|
14
|
+
from ..i18n import _
|
|
15
|
+
|
|
16
|
+
PREVIEW_ROWS = 1000
|
|
17
|
+
MAX_WAIT_SECONDS = 300
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_origin(request: Request) -> Literal["api", "web", "mcp"]:
|
|
21
|
+
hint = request.headers.get("X-Decision-Layer-Client")
|
|
22
|
+
return hint if hint in ("web", "mcp") else "api"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class JobFailed(DecisionLayerError):
|
|
26
|
+
"""The run's last background job raised; code/message are the recorded ones."""
|
|
27
|
+
http_status = 422
|
|
28
|
+
|
|
29
|
+
def __init__(self, error: dict) -> None:
|
|
30
|
+
super().__init__(error.get("message", ""))
|
|
31
|
+
self.code = error.get("code", "JOB_FAILED")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NoResult(DecisionLayerError):
|
|
35
|
+
code = "NO_RESULT"
|
|
36
|
+
http_status = 404
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SourceAdminRequired(DecisionLayerError):
|
|
40
|
+
code = "SOURCE_ADMIN_REQUIRED"
|
|
41
|
+
http_status = 403
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RecipePermissionRequired(DecisionLayerError):
|
|
45
|
+
code = "RECIPE_PERMISSION_REQUIRED"
|
|
46
|
+
http_status = 403
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def require_recipe_permission(settings: Settings, caller: CallerInfo, *, publish: bool = False) -> None:
|
|
50
|
+
# Explicit local service access belongs to the person operating this runtime.
|
|
51
|
+
# It is never a multi-user identity; shared mode is rejected at startup.
|
|
52
|
+
local_owner = settings.allow_service_credentials and caller.authentication == "local_service" and caller.subject in (
|
|
53
|
+
"service:decision-layer", "service:local-development")
|
|
54
|
+
subjects = settings.recipe_publishers if publish else (*settings.recipe_editors, *settings.recipe_publishers)
|
|
55
|
+
if not local_owner and caller.subject not in subjects:
|
|
56
|
+
raise RecipePermissionRequired("Recipe publication requires publisher permission." if publish
|
|
57
|
+
else "Recipe changes require editor permission.")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class MethodPermissionRequired(DecisionLayerError):
|
|
61
|
+
code = "METHOD_PERMISSION_REQUIRED"
|
|
62
|
+
http_status = 403
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def require_method_permission(settings: Settings, caller: CallerInfo, *, publish: bool = False):
|
|
66
|
+
local_owner = settings.allow_service_credentials and caller.authentication == "local_service" and caller.subject in (
|
|
67
|
+
"service:decision-layer", "service:local-development")
|
|
68
|
+
subjects = settings.method_publishers if publish else (*settings.method_editors, *settings.method_publishers)
|
|
69
|
+
if not local_owner and caller.subject not in subjects:
|
|
70
|
+
raise MethodPermissionRequired("Method publication and Python import checks require a Method publisher." if publish
|
|
71
|
+
else "Method drafts require a Method editor.")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def get_credentials(request: Request, authorization: Annotated[str | None, Header()] = None):
|
|
75
|
+
"""The caller's bearer token is passed through to the provider (ADR-024); service credentials
|
|
76
|
+
are a local/dev fallback that must be switched on explicitly (ADR-029)."""
|
|
77
|
+
settings: Settings = request.app.state.settings
|
|
78
|
+
source: EffectiveSource = await request.app.state.source_manager.effective(resolve_secret=False)
|
|
79
|
+
if source.auth_method == "api_secret" and not authorization:
|
|
80
|
+
source = await request.app.state.source_manager.effective()
|
|
81
|
+
if isinstance(request.app.state.provider, ConfiguredSemanticProvider):
|
|
82
|
+
request.app.state.provider.bind(source)
|
|
83
|
+
return credentials(authorization, allow_service=settings.allow_service_credentials,
|
|
84
|
+
secret=source.api_secret, groups=source.service_groups, auth_method=source.auth_method)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
Creds = Annotated[RequestCredentials | ServiceCredentials | AnonymousServiceCredentials, Depends(get_credentials)]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def get_caller(request: Request, creds: Creds) -> CallerInfo:
|
|
91
|
+
return await identify(request.app.state.provider, creds)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
Caller = Annotated[CallerInfo, Depends(get_caller)]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def localized(m: MethodManifest) -> MethodManifest:
|
|
98
|
+
"""Manifest texts are English in code; serve them in the request's language."""
|
|
99
|
+
return m.model_copy(update={
|
|
100
|
+
"description": _(m.description),
|
|
101
|
+
"label": _(m.label),
|
|
102
|
+
"roles": {k: r.model_copy(update={"description": _(r.description), "label": _(r.label)}) for k, r in m.roles.items()},
|
|
103
|
+
"parameters": {k: p.model_copy(update={"description": _(p.description), "label": _(p.label)}) for k, p in m.parameters.items()},
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def waited(settings: Settings, wait: float | None) -> float:
|
|
108
|
+
return max(0.0, min(settings.request_wait_seconds if wait is None else wait, MAX_WAIT_SECONDS))
|
|
109
|
+
|
|
110
|
+
def accepted(run: Run) -> JSONResponse:
|
|
111
|
+
return JSONResponse(status_code=202, content={
|
|
112
|
+
"run_id": run.id, "status": "running", "running": run.running.model_dump(mode="json") if run.running else None,
|
|
113
|
+
"poll": f"/runs/{run.id}/result"})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Methods HTTP routes; analytical work stays in the canonical engine."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from fastapi import Request
|
|
4
|
+
from ..core.models import MethodManifest, PlanStep, Result
|
|
5
|
+
from ..methods.discovery import MethodSearchRequest, find_methods
|
|
6
|
+
from ..methods.context import ExecutionContext
|
|
7
|
+
from ..service import MethodRunRequest
|
|
8
|
+
from ..methods.assets import DraftRequest, RevisionRequest
|
|
9
|
+
from ..core.errors import DecisionLayerError
|
|
10
|
+
from ..methods.invocation import invocation_inputs
|
|
11
|
+
from fastapi import APIRouter
|
|
12
|
+
from .dependencies import Caller, Creds, accepted, localized, run_origin, waited
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FormatRequest(BaseModel):
|
|
17
|
+
model_config = ConfigDict(extra="forbid")
|
|
18
|
+
content: str = Field(min_length=1, max_length=150_000)
|
|
19
|
+
|
|
20
|
+
def create_router(settings, engine):
|
|
21
|
+
registry = engine.registry
|
|
22
|
+
router = APIRouter()
|
|
23
|
+
@router.get("/methods", response_model=list[MethodManifest])
|
|
24
|
+
async def methods() -> list[MethodManifest]:
|
|
25
|
+
return [localized(m) for m in registry.manifests()]
|
|
26
|
+
|
|
27
|
+
@router.post("/methods:search")
|
|
28
|
+
async def method_search(req: MethodSearchRequest, creds: Creds, caller: Caller):
|
|
29
|
+
catalog = await engine.provider.discover(creds)
|
|
30
|
+
report = find_methods(registry, ExecutionContext(engine.provider, creds, catalog), req)
|
|
31
|
+
for candidate in report["candidates"]:
|
|
32
|
+
candidate["manifest"] = localized(MethodManifest.model_validate(candidate["manifest"])).model_dump(mode="json")
|
|
33
|
+
return report
|
|
34
|
+
|
|
35
|
+
@router.get("/method-guides/{name}")
|
|
36
|
+
async def authoring_guide(name: str):
|
|
37
|
+
from ..methods.skills import get_authoring_guide
|
|
38
|
+
return get_authoring_guide(name)
|
|
39
|
+
|
|
40
|
+
def permitted(caller, publish=False):
|
|
41
|
+
from .dependencies import require_method_permission
|
|
42
|
+
from ..methods.assets import AssetError
|
|
43
|
+
if not settings.allow_method_editing or not settings.method_assets_dir:
|
|
44
|
+
raise AssetError("Method editing is disabled by the operator")
|
|
45
|
+
require_method_permission(settings, caller, publish=publish)
|
|
46
|
+
|
|
47
|
+
@router.get("/methods/{name}/source")
|
|
48
|
+
async def source(name: str, caller: Caller, request: Request):
|
|
49
|
+
asset = registry.get(name)
|
|
50
|
+
store = request.app.state.method_assets
|
|
51
|
+
can_edit = can_publish = False
|
|
52
|
+
try:
|
|
53
|
+
permitted(caller);can_edit = True
|
|
54
|
+
permitted(caller, True);can_publish = True
|
|
55
|
+
except DecisionLayerError:
|
|
56
|
+
pass
|
|
57
|
+
skill = asset.manifest.implementation == "skill"
|
|
58
|
+
snapshot = None if skill else getattr(asset, "source_snapshot", None)
|
|
59
|
+
return {"name":name, "version":asset.manifest.version,"implementation":asset.manifest.implementation,
|
|
60
|
+
"content":asset.source_text if skill else snapshot.editor_content if snapshot else None,
|
|
61
|
+
"code":snapshot,"permissions":{"edit":can_edit,"publish":can_publish},
|
|
62
|
+
"draft":store.draft(name) if can_edit else None,
|
|
63
|
+
"history":[{key:value for key,value in entry.items() if key in ("version","published_by","published_at")}
|
|
64
|
+
for entry in store.history(name)]}
|
|
65
|
+
|
|
66
|
+
@router.put("/methods/{name}/draft")
|
|
67
|
+
async def save_draft(name: str, req: DraftRequest, caller: Caller, request: Request):
|
|
68
|
+
permitted(caller)
|
|
69
|
+
return request.app.state.method_assets.save(name, req, caller.subject)
|
|
70
|
+
|
|
71
|
+
@router.post("/methods/{name}/source:format")
|
|
72
|
+
async def format_source(name: str, req: FormatRequest, caller: Caller):
|
|
73
|
+
from starlette.concurrency import run_in_threadpool
|
|
74
|
+
from ..methods.assets import AssetError
|
|
75
|
+
from ..methods.formatting import format_python
|
|
76
|
+
|
|
77
|
+
permitted(caller)
|
|
78
|
+
if registry.get(name).manifest.implementation != "python":
|
|
79
|
+
raise AssetError("Formatting is available for Python Methods only")
|
|
80
|
+
content = await run_in_threadpool(format_python, req.content)
|
|
81
|
+
return {"content": content, "changed": content != req.content}
|
|
82
|
+
|
|
83
|
+
@router.post("/methods/{name}/draft:check")
|
|
84
|
+
async def check_draft(name: str, req: RevisionRequest, caller: Caller, request: Request):
|
|
85
|
+
python = registry.get(name).manifest.implementation == "python"
|
|
86
|
+
permitted(caller, python)
|
|
87
|
+
if python and not req.reviewed:
|
|
88
|
+
from ..methods.assets import AssetError
|
|
89
|
+
raise AssetError("Review and trust this Python module before executing registration checks")
|
|
90
|
+
return await request.app.state.method_assets.check(name, req.revision)
|
|
91
|
+
|
|
92
|
+
@router.post("/methods/{name}/draft:publish")
|
|
93
|
+
async def publish_draft(name: str, req: RevisionRequest, caller: Caller, request: Request):
|
|
94
|
+
permitted(caller, True)
|
|
95
|
+
entry = request.app.state.method_assets.publish(name, req, caller.subject)
|
|
96
|
+
return {"name":name,"version":entry["version"],"published_at":entry["published_at"]}
|
|
97
|
+
|
|
98
|
+
@router.get("/methods/{name}/guide")
|
|
99
|
+
async def method_guide(name: str):
|
|
100
|
+
from ..methods.skills import SkillMethod
|
|
101
|
+
from ..methods.registry import InvalidBinding
|
|
102
|
+
asset = registry.get(name)
|
|
103
|
+
if not isinstance(asset, SkillMethod):
|
|
104
|
+
raise InvalidBinding("This is an executable Python Method, not a Skill")
|
|
105
|
+
return asset.guide
|
|
106
|
+
|
|
107
|
+
@router.get("/methods/{name}", response_model=MethodManifest)
|
|
108
|
+
async def method(name: str, version: str | None = None) -> MethodManifest:
|
|
109
|
+
return localized(registry.get(name, version).manifest)
|
|
110
|
+
|
|
111
|
+
@router.post("/methods/{name}:run", response_model=Result, responses={202: {"description": "still running"}})
|
|
112
|
+
async def method_run(name: str, req: MethodRunRequest, creds: Creds, caller: Caller, request: Request,
|
|
113
|
+
wait: float | None = None):
|
|
114
|
+
"""Ad-hoc Method run, stored as a single-step Run owned by the caller. Answers the Result when it
|
|
115
|
+
finishes within `wait` seconds, else 202 {run_id, poll} — then poll GET /runs/{run_id}/result."""
|
|
116
|
+
bindings = invocation_inputs(registry.get(name, req.method_version).manifest, bindings=req.bindings,
|
|
117
|
+
measures=req.measures, dimensions=req.dimensions, entity=req.entity)
|
|
118
|
+
scope = req.scope.as_dict()
|
|
119
|
+
scope["filters"] = [*(scope.get("filters") or []), *[item.model_dump(mode="json") for item in req.filters]]
|
|
120
|
+
run, result = await engine.adhoc(creds, caller, PlanStep(method=name, method_version=req.method_version, bindings=bindings, params=req.params),
|
|
121
|
+
scope, wait=waited(settings, wait), origin=run_origin(request), question=req.question, author=req.author)
|
|
122
|
+
return result if result is not None else accepted(run)
|
|
123
|
+
|
|
124
|
+
return router
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Recipes HTTP routes; analytical work stays in the canonical engine."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import yaml
|
|
4
|
+
from fastapi import Query, Request
|
|
5
|
+
from fastapi.responses import JSONResponse, Response
|
|
6
|
+
from ..i18n import _
|
|
7
|
+
from ..core.models import Recipe, Run
|
|
8
|
+
from ..recipes.loader import parse_recipe
|
|
9
|
+
from ..service import RecipeSearchRequest, RecipeConfigureRequest, RecipePreviewRequest, RecipePublishRequest, RecipeSaveRequest, RecipeYamlRequest
|
|
10
|
+
from ..i18n import _
|
|
11
|
+
from fastapi import APIRouter
|
|
12
|
+
from .dependencies import Caller, Creds, run_origin, waited, require_recipe_permission
|
|
13
|
+
|
|
14
|
+
from ..recipes.authoring import check_recipe_semantics
|
|
15
|
+
|
|
16
|
+
def create_router(settings, provider, recipes, engine):
|
|
17
|
+
registry = engine.registry
|
|
18
|
+
router = APIRouter()
|
|
19
|
+
@router.get("/recipes", response_model=list[Recipe])
|
|
20
|
+
async def recipes_list(_caller: Caller) -> list[Recipe]:
|
|
21
|
+
return recipes.list()
|
|
22
|
+
|
|
23
|
+
@router.post("/recipes:search")
|
|
24
|
+
async def recipe_search(req: RecipeSearchRequest, creds: Creds, caller: Caller):
|
|
25
|
+
from ..recipes.routing import search_recipes
|
|
26
|
+
return await search_recipes(recipes, provider, creds, req.question, req.goals, req.inputs, req.limit)
|
|
27
|
+
|
|
28
|
+
@router.get("/recipes:drafts", response_model=list[Recipe])
|
|
29
|
+
async def recipe_drafts(_caller: Caller) -> list[Recipe]:
|
|
30
|
+
require_recipe_permission(settings, _caller)
|
|
31
|
+
return recipes.list_drafts()
|
|
32
|
+
|
|
33
|
+
@router.post("/recipes:configure-step", response_model=Recipe)
|
|
34
|
+
async def configure_recipe_step(req: RecipeConfigureRequest, caller: Caller, creds: Creds):
|
|
35
|
+
from ..recipes.configuration import configure_step
|
|
36
|
+
from ..recipes.authoring import RecipeEditError
|
|
37
|
+
try:
|
|
38
|
+
catalog = None
|
|
39
|
+
if 0 <= req.step_index < len(req.recipe.steps):
|
|
40
|
+
manifest = registry.get(req.recipe.steps[req.step_index].method).manifest
|
|
41
|
+
if any(role.default_binding == "unit_count" for role in manifest.roles.values()):
|
|
42
|
+
catalog = await provider.discover(creds)
|
|
43
|
+
recipe = configure_step(req.recipe, req.step_index, req.reset_parameters, catalog=catalog, registry=registry)
|
|
44
|
+
for spec in recipe.inputs.values():
|
|
45
|
+
spec.label = _(spec.label)
|
|
46
|
+
return recipe
|
|
47
|
+
except (ValueError, KeyError) as exc:
|
|
48
|
+
raise RecipeEditError(str(exc), field="steps") from exc
|
|
49
|
+
|
|
50
|
+
@router.post("/recipes:format")
|
|
51
|
+
async def recipe_format(recipe: Recipe, _caller: Caller) -> dict[str, str]:
|
|
52
|
+
recipes.validate(recipe)
|
|
53
|
+
return {"yaml": yaml.safe_dump(recipe.model_dump(mode="json"), allow_unicode=True, sort_keys=False)}
|
|
54
|
+
|
|
55
|
+
@router.post("/recipes:parse", response_model=Recipe)
|
|
56
|
+
async def recipe_parse(req: RecipeYamlRequest, _caller: Caller) -> Recipe:
|
|
57
|
+
from ..recipes.authoring import RecipeEditError
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
document = yaml.safe_load(req.yaml)
|
|
61
|
+
if not isinstance(document, dict):
|
|
62
|
+
raise RecipeEditError(_("Recipe YAML must be a mapping."))
|
|
63
|
+
parsed = parse_recipe(document, provider.name, provider.instance)
|
|
64
|
+
except yaml.YAMLError as error:
|
|
65
|
+
raise RecipeEditError(_("Recipe YAML syntax is invalid: {error}", error=error)) from error
|
|
66
|
+
except ValueError as error:
|
|
67
|
+
raise RecipeEditError(_("Recipe YAML structure is invalid: {error}", error=error)) from error
|
|
68
|
+
recipes.validate(parsed)
|
|
69
|
+
return parsed
|
|
70
|
+
|
|
71
|
+
@router.post("/recipes:validate")
|
|
72
|
+
async def recipe_validate(recipe: Recipe, _caller: Caller, creds: Creds, live: bool = False) -> dict[str, bool]:
|
|
73
|
+
"""Validate the shared static contract and, optionally, caller-visible semantic references."""
|
|
74
|
+
recipes.validate(recipe)
|
|
75
|
+
if live:
|
|
76
|
+
await check_recipe_semantics(recipe, provider, creds)
|
|
77
|
+
return {"valid": True, "semantic_checked": live}
|
|
78
|
+
|
|
79
|
+
@router.post("/recipes:preview", response_model=Run, responses={202: {"description": "preview still running"}})
|
|
80
|
+
async def recipe_preview(req: RecipePreviewRequest, creds: Creds, caller: Caller, request: Request, wait: float | None = None):
|
|
81
|
+
"""Execute an unsaved pipeline prefix with normal validation, access and query limits."""
|
|
82
|
+
recipes.validate(req.recipe)
|
|
83
|
+
run = await engine.preview(creds, caller, req.recipe, req.step_index, req.scope.as_dict(), wait=waited(settings, wait), origin=run_origin(request))
|
|
84
|
+
return JSONResponse(status_code=202, content=run.model_dump(mode="json")) if run.running else run
|
|
85
|
+
|
|
86
|
+
@router.get("/recipes/{name}/edit", response_model=Recipe)
|
|
87
|
+
async def recipe_edit(name: str, _caller: Caller) -> Recipe:
|
|
88
|
+
require_recipe_permission(settings, _caller)
|
|
89
|
+
return recipes.get(name, include_drafts=True)
|
|
90
|
+
|
|
91
|
+
@router.post("/recipes/{name}/publish", response_model=Recipe)
|
|
92
|
+
async def recipe_publish(name: str, req: RecipePublishRequest, _caller: Caller, creds: Creds) -> Recipe:
|
|
93
|
+
require_recipe_permission(settings, _caller, publish=True)
|
|
94
|
+
draft = recipes.get(name, req.base_version, include_drafts=True)
|
|
95
|
+
recipes.validate(draft)
|
|
96
|
+
await check_recipe_semantics(draft, provider, creds)
|
|
97
|
+
return recipes.publish(name, req.base_version, actor=_caller.subject)
|
|
98
|
+
|
|
99
|
+
@router.get("/recipes/{name}/reuse")
|
|
100
|
+
async def recipe_reuse(name: str, _caller: Caller):
|
|
101
|
+
from ..recipes.routing import reuse_contract
|
|
102
|
+
return reuse_contract(recipes.get(name), registry=registry)
|
|
103
|
+
|
|
104
|
+
@router.get("/recipes/{name}", response_model=Recipe)
|
|
105
|
+
async def recipe_get(name: str, _caller: Caller) -> Recipe:
|
|
106
|
+
return recipes.get(name)
|
|
107
|
+
|
|
108
|
+
@router.put("/recipes/{name}", response_model=Recipe)
|
|
109
|
+
async def recipe_save(name: str, req: RecipeSaveRequest, _caller: Caller) -> Recipe:
|
|
110
|
+
require_recipe_permission(settings, _caller, publish=req.recipe.status == "published")
|
|
111
|
+
from ..recipes.authoring import RecipeEditError
|
|
112
|
+
|
|
113
|
+
if req.recipe.name != name:
|
|
114
|
+
raise RecipeEditError("Recipe name must match the URL.")
|
|
115
|
+
return recipes.save(req.recipe, req.base_version, actor=_caller.subject)
|
|
116
|
+
|
|
117
|
+
@router.delete("/recipes/{name}", status_code=204)
|
|
118
|
+
async def recipe_delete(name: str, _caller: Caller, base_version: str = Query(...)) -> Response:
|
|
119
|
+
require_recipe_permission(settings, _caller, publish=True)
|
|
120
|
+
recipes.delete(name, base_version, actor=_caller.subject)
|
|
121
|
+
return Response(status_code=204)
|
|
122
|
+
|
|
123
|
+
@router.post("/recipes/{name}/restore", response_model=Recipe)
|
|
124
|
+
async def recipe_restore(name: str, req: RecipePublishRequest, caller: Caller) -> Recipe:
|
|
125
|
+
require_recipe_permission(settings, caller, publish=True)
|
|
126
|
+
return recipes.restore(name, req.base_version, actor=caller.subject)
|
|
127
|
+
|
|
128
|
+
return router
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Runs HTTP routes; analytical work stays in the canonical engine."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from ..runs.skills import SkillQueryRequest, SkillFinishRequest
|
|
4
|
+
from fastapi import Query, Request
|
|
5
|
+
from fastapi.responses import JSONResponse, Response
|
|
6
|
+
from ..i18n import _
|
|
7
|
+
from ..core.models import PlanStep, Recipe, Result, Run
|
|
8
|
+
from ..methods.invocation import invocation_inputs
|
|
9
|
+
from ..recipes.from_run import RecipeCandidate, RunPromotionError, candidate_from_run, runtime_recipe_from_run
|
|
10
|
+
from ..service import MethodProposalRequest, RunCompleteRequest, RunRecipeRequest, RunScopeRequest, RunShareRequest, RunStartRequest, RunStepRequest, RunUseRecipeRequest
|
|
11
|
+
from ..service import BlockedAnalysisRequest, RemediationCreateRequest, RemediationReviewRequest, RemediationCheckRequest, RemediationRetryRequest
|
|
12
|
+
from ..runs import remediation
|
|
13
|
+
from ..i18n import _
|
|
14
|
+
from fastapi import APIRouter
|
|
15
|
+
from .dependencies import Caller, Creds, JobFailed, NoResult, accepted, run_origin, waited, require_recipe_permission
|
|
16
|
+
|
|
17
|
+
from ..recipes.authoring import check_recipe_semantics
|
|
18
|
+
|
|
19
|
+
def create_router(settings, provider, recipes, engine):
|
|
20
|
+
registry = engine.registry
|
|
21
|
+
router = APIRouter()
|
|
22
|
+
@router.post("/runs", response_model=Run, responses={202: {"description": "pipeline still running"}})
|
|
23
|
+
async def run_start(req: RunStartRequest, creds: Creds, caller: Caller, request: Request, wait: float | None = None):
|
|
24
|
+
"""Start a Run. A pipeline Recipe executes right away (202 with the run while it is still running);
|
|
25
|
+
an investigation Recipe waits for steps."""
|
|
26
|
+
run = await engine.start(creds, caller, recipe=req.recipe, question=req.question, scope=req.scope.as_dict(),
|
|
27
|
+
wait=waited(settings, wait), origin=run_origin(request), author=req.author,
|
|
28
|
+
goals=req.goals, recipe_selection=req.recipe_selection,
|
|
29
|
+
recipe_review=req.recipe_review, interactive=req.interactive, guide=req.guide)
|
|
30
|
+
return JSONResponse(status_code=202, content=run.model_dump(mode="json")) if run.running else run
|
|
31
|
+
|
|
32
|
+
@router.get("/runs", response_model=list[Run])
|
|
33
|
+
async def runs_list(caller: Caller, limit: int = 50, recipe: str | None = None) -> list[Run]:
|
|
34
|
+
"""The caller's own runs, newest first."""
|
|
35
|
+
return await engine.list(caller, limit, recipe)
|
|
36
|
+
|
|
37
|
+
@router.post("/analyses:blocked", response_model=Run)
|
|
38
|
+
async def analysis_blocked(req: BlockedAnalysisRequest, caller: Caller, request: Request):
|
|
39
|
+
"""Record an unanswered question and proposed improvements, without executing a query."""
|
|
40
|
+
return await remediation.record_blocked(engine, caller, req, run_origin(request))
|
|
41
|
+
|
|
42
|
+
@router.post("/runs/{run_id}/method-proposal")
|
|
43
|
+
async def run_method_proposal(run_id: str, req: MethodProposalRequest, caller: Caller):
|
|
44
|
+
from ..runs.proposals import method_proposal
|
|
45
|
+
run = await engine._owned(run_id, caller)
|
|
46
|
+
return method_proposal(run, req, settings.issue_repository)
|
|
47
|
+
|
|
48
|
+
@router.post("/runs/{run_id}:use-recipe", response_model=Run)
|
|
49
|
+
async def run_use_recipe(run_id: str, req: RunUseRecipeRequest, creds: Creds, caller: Caller, wait: float | None = None):
|
|
50
|
+
run = await engine.use_recipe(creds, caller, run_id, req.recipe, req.selection, req.inputs, waited(settings, wait))
|
|
51
|
+
return JSONResponse(status_code=202, content=run.model_dump(mode="json")) if run.running else run
|
|
52
|
+
|
|
53
|
+
@router.get("/runs/{run_id}/recipe-candidate", response_model=RecipeCandidate)
|
|
54
|
+
async def run_recipe_candidate(run_id: str, creds: Creds, caller: Caller, indices: list[int] = Query(...)) -> RecipeCandidate:
|
|
55
|
+
run = await engine._owned(run_id, caller)
|
|
56
|
+
require_recipe_permission(settings, caller)
|
|
57
|
+
candidate = candidate_from_run(run, indices, registry=registry)
|
|
58
|
+
await check_recipe_semantics(candidate.recipe, provider, creds)
|
|
59
|
+
return candidate
|
|
60
|
+
|
|
61
|
+
@router.post("/runs/{run_id}/recipe", response_model=Recipe)
|
|
62
|
+
async def run_recipe_register(run_id: str, creds: Creds, caller: Caller, body: RunRecipeRequest = RunRecipeRequest()) -> Recipe:
|
|
63
|
+
run = await engine._owned(run_id, caller)
|
|
64
|
+
require_recipe_permission(settings, caller, publish=body.reviewed)
|
|
65
|
+
if body.reviewed and body.recipe is None:
|
|
66
|
+
raise RunPromotionError("Submit the reviewed Recipe explicitly before publication.")
|
|
67
|
+
if run.status != "completed" or run.running:
|
|
68
|
+
raise RunPromotionError("Complete the analysis before registering its procedure.")
|
|
69
|
+
from ..recipes.loader import UnknownRecipe
|
|
70
|
+
name = f"analysis-from-{run.id.lower()}"
|
|
71
|
+
if body.recipe is None:
|
|
72
|
+
try:
|
|
73
|
+
existing = recipes.get(name, include_drafts=True)
|
|
74
|
+
except UnknownRecipe:
|
|
75
|
+
existing = None
|
|
76
|
+
if existing and existing.origin_runs == [run.id]:
|
|
77
|
+
await check_recipe_semantics(existing, provider, creds)
|
|
78
|
+
return existing
|
|
79
|
+
recipe = (body.recipe or runtime_recipe_from_run(run, registry=registry)).model_copy(update={
|
|
80
|
+
"name": name, "origin_runs": [run.id], "status": "published" if body.reviewed else "draft"})
|
|
81
|
+
from ..recipes.authoring import validate_recipe
|
|
82
|
+
validate_recipe(recipe, registry=registry)
|
|
83
|
+
for step in recipe.steps:
|
|
84
|
+
if step.method_version and registry.get(step.method, step.method_version).manifest.version != step.method_version:
|
|
85
|
+
raise RunPromotionError("The recorded Method version is not installed. Edit the candidate before registering it.")
|
|
86
|
+
await check_recipe_semantics(recipe, provider, creds)
|
|
87
|
+
# A repeated click returns the same version instead of making another Recipe.
|
|
88
|
+
from ..recipes.loader import UnknownRecipe
|
|
89
|
+
try:
|
|
90
|
+
existing = recipes.get(recipe.name, include_drafts=True)
|
|
91
|
+
except UnknownRecipe:
|
|
92
|
+
return recipes.save(recipe, None, actor=caller.subject)
|
|
93
|
+
if existing.origin_runs != [run.id]:
|
|
94
|
+
from ..recipes.authoring import RecipeConflict
|
|
95
|
+
raise RecipeConflict("A different Recipe already uses this name.")
|
|
96
|
+
if existing.model_dump() != recipe.model_dump():
|
|
97
|
+
from ..recipes.authoring import RecipeConflict
|
|
98
|
+
raise RecipeConflict("This Run already has a different Recipe. Save changes as a new version in the editor.")
|
|
99
|
+
return existing
|
|
100
|
+
|
|
101
|
+
@router.get("/runs/{run_id}", response_model=Run)
|
|
102
|
+
async def run_get(run_id: str, creds: Creds, caller: Caller) -> Run:
|
|
103
|
+
return await engine.get(creds, caller, run_id)
|
|
104
|
+
|
|
105
|
+
@router.post("/runs/{run_id}/remediations", response_model=Run)
|
|
106
|
+
async def remediation_create(run_id: str, req: RemediationCreateRequest, caller: Caller):
|
|
107
|
+
return await remediation.create(engine, caller, run_id, req)
|
|
108
|
+
|
|
109
|
+
@router.put("/runs/{run_id}/remediations/{item_id}", response_model=Run)
|
|
110
|
+
async def remediation_review(run_id: str, item_id: str, req: RemediationReviewRequest, caller: Caller):
|
|
111
|
+
return await remediation.review(engine, caller, run_id, item_id, req)
|
|
112
|
+
|
|
113
|
+
@router.post("/runs/{run_id}/remediations/{item_id}:check", response_model=Run)
|
|
114
|
+
async def remediation_check(run_id: str, item_id: str, req: RemediationCheckRequest, creds: Creds, caller: Caller):
|
|
115
|
+
return await remediation.recheck(engine, creds, caller, run_id, item_id, req)
|
|
116
|
+
|
|
117
|
+
@router.post("/runs/{run_id}/remediations/{item_id}:retry", response_model=Run)
|
|
118
|
+
async def remediation_retry(run_id: str, item_id: str, req: RemediationRetryRequest, creds: Creds,
|
|
119
|
+
caller: Caller, request: Request, wait: float | None = None):
|
|
120
|
+
run = await remediation.retry(engine, creds, caller, run_id, item_id, req, run_origin(request), waited(settings, wait))
|
|
121
|
+
return JSONResponse(status_code=202, content=run.model_dump(mode="json")) if run.running else run
|
|
122
|
+
|
|
123
|
+
@router.put("/runs/{run_id}/scope", response_model=Run)
|
|
124
|
+
async def run_scope(run_id: str, req: RunScopeRequest, creds: Creds, caller: Caller, wait: float | None = None):
|
|
125
|
+
run = await engine.set_scope(creds, caller, run_id, req.scope.as_dict(), req.base_revision, waited(settings, wait))
|
|
126
|
+
return JSONResponse(status_code=202, content=run.model_dump(mode="json")) if run.running else run
|
|
127
|
+
|
|
128
|
+
@router.delete("/runs/{run_id}", status_code=204)
|
|
129
|
+
async def run_delete(run_id: str, caller: Caller) -> Response:
|
|
130
|
+
await engine.delete(caller, run_id)
|
|
131
|
+
return Response(status_code=204)
|
|
132
|
+
|
|
133
|
+
@router.get("/runs/{run_id}/result", response_model=Result, responses={202: {"description": "still running"}})
|
|
134
|
+
async def run_result(run_id: str, creds: Creds, caller: Caller):
|
|
135
|
+
"""The latest step's Result — 202 while a job runs, the recorded error if the job failed."""
|
|
136
|
+
run = await engine.get(creds, caller, run_id)
|
|
137
|
+
if run.running:
|
|
138
|
+
return accepted(run)
|
|
139
|
+
if run.error:
|
|
140
|
+
raise JobFailed(run.error)
|
|
141
|
+
if run.needs_input:
|
|
142
|
+
return Result(status="needs_input", run_id=run.id, needs_input={**run.needs_input, "scope_revision": run.scope_revision})
|
|
143
|
+
if not run.steps:
|
|
144
|
+
raise NoResult(_("No step has run yet"), run_id=run_id)
|
|
145
|
+
return run.steps[-1].result
|
|
146
|
+
|
|
147
|
+
@router.post("/runs/{run_id}/steps", response_model=Result, responses={202: {"description": "still running"}})
|
|
148
|
+
async def run_step(run_id: str, req: RunStepRequest, creds: Creds, caller: Caller, wait: float | None = None):
|
|
149
|
+
values = req.model_dump(exclude={"author", "measures", "dimensions", "entity"})
|
|
150
|
+
maps = {"measures": req.measures, "dimensions": req.dimensions, "entity": req.entity} if req.measures or req.dimensions or req.entity else None
|
|
151
|
+
run, result = await engine.step(creds, caller, run_id, PlanStep(**values), wait=waited(settings, wait), author=req.author, input_maps=maps)
|
|
152
|
+
return result if result is not None else accepted(run)
|
|
153
|
+
|
|
154
|
+
@router.post("/runs/{run_id}/steps/{step_id}/queries")
|
|
155
|
+
async def skill_query(run_id: str, step_id: str, req: SkillQueryRequest, creds: Creds, caller: Caller, wait: float | None = None):
|
|
156
|
+
run, query = await engine.skill_query(creds, caller, run_id, step_id, req, wait=waited(settings, wait))
|
|
157
|
+
return query if query is not None else accepted(run)
|
|
158
|
+
|
|
159
|
+
@router.post("/runs/{run_id}/steps/{step_id}:finish", response_model=Run)
|
|
160
|
+
async def finish_skill(run_id: str, step_id: str, req: SkillFinishRequest, creds: Creds, caller: Caller, wait: float | None = None):
|
|
161
|
+
run = await engine.finish_skill(creds, caller, run_id, step_id, req, wait=waited(settings, wait))
|
|
162
|
+
return JSONResponse(status_code=202, content=run.model_dump(mode="json")) if run.running else run
|
|
163
|
+
|
|
164
|
+
@router.post("/runs/{run_id}:complete", response_model=Run)
|
|
165
|
+
async def run_complete(run_id: str, req: RunCompleteRequest, caller: Caller) -> Run:
|
|
166
|
+
return await engine.complete(caller, run_id, req.summary, req.conclusion, req.author)
|
|
167
|
+
|
|
168
|
+
@router.post("/runs/{run_id}:share", response_model=Run)
|
|
169
|
+
async def run_share(run_id: str, req: RunShareRequest, caller: Caller) -> Run:
|
|
170
|
+
"""Owner only. Replaces the read-only share list; "*" = any authenticated caller."""
|
|
171
|
+
return await engine.share(caller, run_id, req.subjects)
|
|
172
|
+
|
|
173
|
+
return router
|