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.
Files changed (117) hide show
  1. decision_layer/__init__.py +1 -0
  2. decision_layer/__main__.py +4 -0
  3. decision_layer/api/__init__.py +0 -0
  4. decision_layer/api/app.py +94 -0
  5. decision_layer/api/dependencies.py +113 -0
  6. decision_layer/api/methods.py +124 -0
  7. decision_layer/api/recipes.py +128 -0
  8. decision_layer/api/runs.py +173 -0
  9. decision_layer/api/semantic.py +51 -0
  10. decision_layer/api/sources.py +130 -0
  11. decision_layer/auth.py +63 -0
  12. decision_layer/cli.py +178 -0
  13. decision_layer/core/__init__.py +0 -0
  14. decision_layer/core/errors.py +54 -0
  15. decision_layer/core/ids.py +95 -0
  16. decision_layer/core/interpretation.py +11 -0
  17. decision_layer/core/models.py +750 -0
  18. decision_layer/core/periods.py +113 -0
  19. decision_layer/dev.py +182 -0
  20. decision_layer/env.py +25 -0
  21. decision_layer/guides/authoring.md +138 -0
  22. decision_layer/guides/compatibility/drilldown-1.0.1.md +25 -0
  23. decision_layer/guides/compatibility/trend_query-1.0.0.md +26 -0
  24. decision_layer/guides/cube-query.md +16 -0
  25. decision_layer/guides/methods/breakdown.md +8 -0
  26. decision_layer/guides/methods/drilldown.md +25 -0
  27. decision_layer/guides/methods/metric.md +8 -0
  28. decision_layer/guides/methods/metric_query.md +22 -0
  29. decision_layer/guides/methods/peer_query.md +29 -0
  30. decision_layer/guides/methods/peers.md +8 -0
  31. decision_layer/guides/methods/trend_query.md +24 -0
  32. decision_layer/i18n/__init__.py +53 -0
  33. decision_layer/i18n/ko.json +408 -0
  34. decision_layer/mcp/__init__.py +0 -0
  35. decision_layer/mcp/server.py +532 -0
  36. decision_layer/methods/__init__.py +32 -0
  37. decision_layer/methods/advanced.py +132 -0
  38. decision_layer/methods/aggregate.py +46 -0
  39. decision_layer/methods/asset_worker.py +8 -0
  40. decision_layer/methods/assets.py +220 -0
  41. decision_layer/methods/authoring.py +138 -0
  42. decision_layer/methods/base.py +35 -0
  43. decision_layer/methods/binary_matching.py +99 -0
  44. decision_layer/methods/calculation.py +176 -0
  45. decision_layer/methods/cem.py +466 -0
  46. decision_layer/methods/change.py +212 -0
  47. decision_layer/methods/common.py +70 -0
  48. decision_layer/methods/compatibility.py +28 -0
  49. decision_layer/methods/computation.py +84 -0
  50. decision_layer/methods/context.py +181 -0
  51. decision_layer/methods/data.py +150 -0
  52. decision_layer/methods/dependencies.py +25 -0
  53. decision_layer/methods/discovery.py +90 -0
  54. decision_layer/methods/drilldown.py +214 -0
  55. decision_layer/methods/formatting.py +48 -0
  56. decision_layer/methods/inputs.py +53 -0
  57. decision_layer/methods/invocation.py +23 -0
  58. decision_layer/methods/legacy_advanced.py +99 -0
  59. decision_layer/methods/loading.py +26 -0
  60. decision_layer/methods/matching.py +208 -0
  61. decision_layer/methods/native_matching.py +192 -0
  62. decision_layer/methods/native_regression.py +227 -0
  63. decision_layer/methods/output.py +83 -0
  64. decision_layer/methods/peer_comparison.py +84 -0
  65. decision_layer/methods/python-style.toml +10 -0
  66. decision_layer/methods/python.py +491 -0
  67. decision_layer/methods/registry.py +306 -0
  68. decision_layer/methods/regression_adjustment.py +93 -0
  69. decision_layer/methods/series_diagnostics.py +20 -0
  70. decision_layer/methods/skills.py +90 -0
  71. decision_layer/methods/source.py +176 -0
  72. decision_layer/methods/statistics.py +324 -0
  73. decision_layer/methods/stats.py +67 -0
  74. decision_layer/methods/time_analysis.py +112 -0
  75. decision_layer/methods/trend.py +109 -0
  76. decision_layer/project.py +169 -0
  77. decision_layer/recipes/__init__.py +0 -0
  78. decision_layer/recipes/authoring.py +194 -0
  79. decision_layer/recipes/configuration.py +63 -0
  80. decision_layer/recipes/from_run.py +158 -0
  81. decision_layer/recipes/loader.py +265 -0
  82. decision_layer/recipes/routing.py +107 -0
  83. decision_layer/runs/__init__.py +0 -0
  84. decision_layer/runs/engine.py +806 -0
  85. decision_layer/runs/expressions.py +100 -0
  86. decision_layer/runs/goals.py +26 -0
  87. decision_layer/runs/jobs.py +44 -0
  88. decision_layer/runs/policy.py +29 -0
  89. decision_layer/runs/proposals.py +24 -0
  90. decision_layer/runs/remediation.py +198 -0
  91. decision_layer/runs/skills.py +171 -0
  92. decision_layer/runs/store.py +179 -0
  93. decision_layer/semantic/__init__.py +0 -0
  94. decision_layer/semantic/credentials.py +47 -0
  95. decision_layer/semantic/provider.py +31 -0
  96. decision_layer/semantic/providers/__init__.py +0 -0
  97. decision_layer/semantic/providers/cube/__init__.py +0 -0
  98. decision_layer/semantic/providers/cube/catalog_mapper.py +121 -0
  99. decision_layer/semantic/providers/cube/client.py +86 -0
  100. decision_layer/semantic/providers/cube/compiler.py +88 -0
  101. decision_layer/semantic/providers/cube/provider.py +124 -0
  102. decision_layer/semantic/providers/dbt/__init__.py +1 -0
  103. decision_layer/semantic/providers/dbt/provider.py +296 -0
  104. decision_layer/service.py +171 -0
  105. decision_layer/settings.py +64 -0
  106. decision_layer/sources/__init__.py +1 -0
  107. decision_layer/sources/config.py +208 -0
  108. decision_layer/sources/provider.py +71 -0
  109. decision_layer/sources/store.py +79 -0
  110. decision_layer/testing.py +95 -0
  111. decision_layer/validation/__init__.py +0 -0
  112. decision_layer/validation/builtin.py +101 -0
  113. decision_layer-0.1.0a1.dist-info/METADATA +322 -0
  114. decision_layer-0.1.0a1.dist-info/RECORD +117 -0
  115. decision_layer-0.1.0a1.dist-info/WHEEL +4 -0
  116. decision_layer-0.1.0a1.dist-info/entry_points.txt +3 -0
  117. decision_layer-0.1.0a1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0a1"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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