plm-skill-kernel 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plm_skill_kernel/__init__.py +33 -0
- plm_skill_kernel/__main__.py +13 -0
- plm_skill_kernel/dispatcher.py +141 -0
- plm_skill_kernel/registry.py +80 -0
- plm_skill_kernel/sdk/__init__.py +29 -0
- plm_skill_kernel/sdk/_registry.py +111 -0
- plm_skill_kernel/sdk/context.py +66 -0
- plm_skill_kernel/sdk/decorator.py +147 -0
- plm_skill_kernel/sdk/types.py +86 -0
- plm_skill_kernel/server.py +173 -0
- plm_skill_kernel/skills/__init__.py +16 -0
- plm_skill_kernel/skills/agents/__init__.py +13 -0
- plm_skill_kernel/skills/agents/suggest.py +191 -0
- plm_skill_kernel/skills/bpmn/__init__.py +9 -0
- plm_skill_kernel/skills/bpmn/generate.py +147 -0
- plm_skill_kernel/skills/cleansing/__init__.py +10 -0
- plm_skill_kernel/skills/cleansing/dedupe.py +162 -0
- plm_skill_kernel/skills/cleansing/normalise.py +178 -0
- plm_skill_kernel/skills/documents/__init__.py +12 -0
- plm_skill_kernel/skills/documents/parse.py +202 -0
- plm_skill_kernel/skills/knowledge/__init__.py +32 -0
- plm_skill_kernel/skills/knowledge/_v1_corpus.py +153 -0
- plm_skill_kernel/skills/knowledge/get_record.py +102 -0
- plm_skill_kernel/skills/knowledge/resolve.py +143 -0
- plm_skill_kernel/skills/knowledge/retrieve.py +143 -0
- plm_skill_kernel/skills/knowledge/search.py +161 -0
- plm_skill_kernel/skills/knowledge/validate.py +222 -0
- plm_skill_kernel/skills_legacy/__init__.py +0 -0
- plm_skill_kernel/skills_legacy/knowledge_pack_loader.py +17 -0
- plm_skill_kernel/skills_legacy/knowledge_search_engine.py +25 -0
- plm_skill_kernel/skills_legacy/plm_skill_registry.py +573 -0
- plm_skill_kernel/skills_legacy/plm_tool_definitions.py +44 -0
- plm_skill_kernel/skills_legacy/skill_orchestrator.py +163 -0
- plm_skill_kernel-1.0.0.dist-info/METADATA +135 -0
- plm_skill_kernel-1.0.0.dist-info/RECORD +38 -0
- plm_skill_kernel-1.0.0.dist-info/WHEEL +5 -0
- plm_skill_kernel-1.0.0.dist-info/entry_points.txt +2 -0
- plm_skill_kernel-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""V1 Skill wire shapes — frozen Pydantic v2 contract (CR.10 §7.bis).
|
|
2
|
+
|
|
3
|
+
The Kernel HTTP server stub at ``POST /v1/skills/{id}/invoke`` accepts
|
|
4
|
+
:class:`SkillInvokeRequest` and returns :class:`SkillInvokeResult`.
|
|
5
|
+
The wire shape is deliberately a near-mirror of CR.10's
|
|
6
|
+
``DispatchRequest`` / ``DispatchResult`` (in plm-engine-core) so the
|
|
7
|
+
V1.1 HTTP loopback closure (D-CONV-M-4) is a straight pass-through.
|
|
8
|
+
|
|
9
|
+
Decision #59 (Conv N / Q-N4 = Option α decorator). The wire shapes
|
|
10
|
+
are independent of the authoring surface — a non-decorator skill (or
|
|
11
|
+
a non-Python skill in V2.0+) would still produce + consume the same
|
|
12
|
+
JSON.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, Dict, Optional
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _FrozenModel(BaseModel):
|
|
22
|
+
"""Internal base — frozen + ``extra="forbid"`` to lock the wire shape.
|
|
23
|
+
|
|
24
|
+
Wave 2 V1 only adds optional fields with safe defaults; required-field
|
|
25
|
+
additions are a V2.0 break and need their own design lock.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SkillInvokeRequest(_FrozenModel):
|
|
32
|
+
"""V1 request body for ``POST /v1/skills/{id}/invoke``.
|
|
33
|
+
|
|
34
|
+
The skill ID lives in the URL path (``{id}``); the body carries
|
|
35
|
+
payload + version + an optional autonomy hint. The hint is purely
|
|
36
|
+
advisory — the Kernel does NOT re-run gating; that already happened
|
|
37
|
+
at the Core selector layer (CR.10).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
payload: Dict[str, Any] = Field(default_factory=dict)
|
|
41
|
+
version: str = Field(min_length=1)
|
|
42
|
+
autonomy_hint: Optional[str] = Field(
|
|
43
|
+
default=None,
|
|
44
|
+
description=(
|
|
45
|
+
"Advisory autonomy level — 'L1' / 'L2' / 'L3'. "
|
|
46
|
+
"Skills MAY consult this to alter behaviour but MUST NOT "
|
|
47
|
+
"treat it as authoritative gating (gating happens at Core)."
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SkillErrorEnvelope(_FrozenModel):
|
|
53
|
+
"""Typed error returned in :class:`SkillInvokeResult.error`.
|
|
54
|
+
|
|
55
|
+
Mirrors the shape of ``plm_shared.errors.ErrorEnvelope`` — the
|
|
56
|
+
``error_code`` is a D-AUDIT-7 code (V1 = 57; new Skill-side codes
|
|
57
|
+
are reserved through ``CAPABILITY_*`` / ``SKILL_*`` / ``KERNEL_*``).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
error_code: str = Field(min_length=1)
|
|
61
|
+
message: str = Field(min_length=1)
|
|
62
|
+
detail: Optional[Dict[str, Any]] = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SkillInvokeResult(_FrozenModel):
|
|
66
|
+
"""V1 response body.
|
|
67
|
+
|
|
68
|
+
Exactly one of ``result`` or ``error`` is populated when ``ok``
|
|
69
|
+
matches. The ``cancelled`` flag is set when a run was cancelled
|
|
70
|
+
mid-flight via the W0.3 / CR.1b RunTaskTracker surface; the
|
|
71
|
+
``deferred_to`` field carries the upstream story ID when the
|
|
72
|
+
Kernel-side deferral path activates.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
ok: bool
|
|
76
|
+
result: Optional[Dict[str, Any]] = None
|
|
77
|
+
error: Optional[SkillErrorEnvelope] = None
|
|
78
|
+
deferred_to: Optional[str] = None
|
|
79
|
+
cancelled: Optional[bool] = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = [
|
|
83
|
+
"SkillErrorEnvelope",
|
|
84
|
+
"SkillInvokeRequest",
|
|
85
|
+
"SkillInvokeResult",
|
|
86
|
+
]
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Kernel HTTP server stub — FastAPI app exposing CR.10 §7.bis.
|
|
2
|
+
|
|
3
|
+
Routes:
|
|
4
|
+
|
|
5
|
+
* ``POST /v1/skills/{skill_id}/invoke`` — primary dispatch surface.
|
|
6
|
+
Body: :class:`SkillInvokeRequest`. Response: :class:`SkillInvokeResult`.
|
|
7
|
+
* ``GET /v1/skills`` — introspection — list registered skills.
|
|
8
|
+
* ``GET /v1/health`` — liveness / readiness probe.
|
|
9
|
+
|
|
10
|
+
Headers honoured on the dispatch endpoint:
|
|
11
|
+
|
|
12
|
+
* ``X-Core-Caller: <UUID>`` — identifies the calling Core instance.
|
|
13
|
+
Threaded through to :class:`SkillContext.core_caller`. NOT validated
|
|
14
|
+
for V1 — a real auth contract lands at V1.1 (analogous to the
|
|
15
|
+
middleware pattern from CR.0 PR-2 in plm-engine-core).
|
|
16
|
+
* ``Idempotency-Key: <uuid>`` — propagated to the SDK context; skills
|
|
17
|
+
MAY use it to dedupe side-effects.
|
|
18
|
+
* ``traceparent`` — W3C trace; propagated to ``ctx.trace_id``.
|
|
19
|
+
|
|
20
|
+
Q-W2A-3 (operator decision): the server runs standalone via
|
|
21
|
+
``python -m plm_skill_kernel`` on port 8100 by default. The ``_cli_entry``
|
|
22
|
+
function below is the ``plm-skill-kernel`` console-script entry.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
from typing import Any, Dict, List, Optional
|
|
30
|
+
|
|
31
|
+
from fastapi import FastAPI, Header, HTTPException, Path
|
|
32
|
+
from fastapi.responses import JSONResponse
|
|
33
|
+
|
|
34
|
+
from .dispatcher import InProcessDispatcher
|
|
35
|
+
from .registry import load_starter_skills
|
|
36
|
+
from .sdk._registry import get_registry
|
|
37
|
+
from .sdk.context import SkillContext
|
|
38
|
+
from .sdk.types import SkillInvokeRequest, SkillInvokeResult
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
DEFAULT_PORT = 8100
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_app(*, load_skills: bool = True) -> FastAPI:
|
|
48
|
+
"""Construct the Kernel FastAPI app.
|
|
49
|
+
|
|
50
|
+
``load_skills`` defaults to True so production callers get a
|
|
51
|
+
fully-wired server out of the box. Tests that want to register a
|
|
52
|
+
custom set of skills (or none) pass ``load_skills=False`` and
|
|
53
|
+
populate the SDK registry themselves.
|
|
54
|
+
"""
|
|
55
|
+
app = FastAPI(
|
|
56
|
+
title="plm-skill-kernel",
|
|
57
|
+
version="0.1.0-alpha",
|
|
58
|
+
description=(
|
|
59
|
+
"TracePulse Skill Kernel — Wave 2 Conv A. "
|
|
60
|
+
"POST /v1/skills/{id}/invoke matches CR.10 §7.bis."
|
|
61
|
+
),
|
|
62
|
+
# Disable docs in V1 — the OpenAPI contract is published by
|
|
63
|
+
# plm-engine-core's CR.8 surface; the Kernel's wire shape is
|
|
64
|
+
# locked separately. V1.1 may re-enable when CR.8 widens to
|
|
65
|
+
# cover Kernel routes.
|
|
66
|
+
docs_url=None,
|
|
67
|
+
redoc_url=None,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if load_skills:
|
|
71
|
+
load_starter_skills()
|
|
72
|
+
|
|
73
|
+
dispatcher = InProcessDispatcher()
|
|
74
|
+
|
|
75
|
+
@app.get("/v1/health")
|
|
76
|
+
async def _health() -> Dict[str, Any]:
|
|
77
|
+
return {
|
|
78
|
+
"liveness": "ok",
|
|
79
|
+
"readiness": "ok",
|
|
80
|
+
"skill_count": len(get_registry().list_all()),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@app.get("/v1/skills")
|
|
84
|
+
async def _list_skills() -> Dict[str, List[Dict[str, Any]]]:
|
|
85
|
+
skills = [
|
|
86
|
+
{
|
|
87
|
+
"skill_id": e.skill_id,
|
|
88
|
+
"version": e.version,
|
|
89
|
+
"description": e.description,
|
|
90
|
+
"min_autonomy": e.min_autonomy,
|
|
91
|
+
"streaming": e.streaming,
|
|
92
|
+
"hitl_required": e.hitl_required,
|
|
93
|
+
}
|
|
94
|
+
for e in get_registry().list_all()
|
|
95
|
+
]
|
|
96
|
+
return {"skills": skills}
|
|
97
|
+
|
|
98
|
+
@app.post(
|
|
99
|
+
"/v1/skills/{skill_id}/invoke",
|
|
100
|
+
response_model=SkillInvokeResult,
|
|
101
|
+
)
|
|
102
|
+
async def _invoke(
|
|
103
|
+
request: SkillInvokeRequest,
|
|
104
|
+
skill_id: str = Path(..., min_length=1),
|
|
105
|
+
x_core_caller: Optional[str] = Header(default=None),
|
|
106
|
+
idempotency_key: Optional[str] = Header(default=None),
|
|
107
|
+
traceparent: Optional[str] = Header(default=None),
|
|
108
|
+
x_tenant_id: Optional[str] = Header(default=None),
|
|
109
|
+
x_run_id: Optional[str] = Header(default=None),
|
|
110
|
+
) -> SkillInvokeResult:
|
|
111
|
+
# Build the per-call context. Tenant + run identifiers come
|
|
112
|
+
# from headers — V1 trusts them; V1.1 will validate against
|
|
113
|
+
# the X-Core-Caller signature.
|
|
114
|
+
ctx = SkillContext(
|
|
115
|
+
skill_id=skill_id,
|
|
116
|
+
skill_version=request.version,
|
|
117
|
+
tenant_id=x_tenant_id or "anonymous",
|
|
118
|
+
run_id=x_run_id,
|
|
119
|
+
trace_id=traceparent,
|
|
120
|
+
autonomy_level=request.autonomy_hint,
|
|
121
|
+
idempotency_key=idempotency_key,
|
|
122
|
+
core_caller=x_core_caller,
|
|
123
|
+
)
|
|
124
|
+
result = await dispatcher.dispatch(skill_id, request, context=ctx)
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
return app
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Module-level app instance for `uvicorn plm_skill_kernel.server:app`.
|
|
131
|
+
# Construction is lazy — tests that don't want skill auto-load can
|
|
132
|
+
# import `build_app` instead.
|
|
133
|
+
app = build_app()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _cli_entry(argv: Optional[List[str]] = None) -> None:
|
|
137
|
+
"""Console-script entry — ``plm-skill-kernel`` / ``python -m plm_skill_kernel``.
|
|
138
|
+
|
|
139
|
+
Q-W2A-3 picked the standalone launch pattern. Default port 8100;
|
|
140
|
+
can be overridden via ``--port`` or ``SKILL_KERNEL_PORT`` env.
|
|
141
|
+
"""
|
|
142
|
+
parser = argparse.ArgumentParser(prog="plm-skill-kernel")
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
"--host", default=os.environ.get("SKILL_KERNEL_HOST", "127.0.0.1")
|
|
145
|
+
)
|
|
146
|
+
parser.add_argument(
|
|
147
|
+
"--port",
|
|
148
|
+
type=int,
|
|
149
|
+
default=int(os.environ.get("SKILL_KERNEL_PORT", str(DEFAULT_PORT))),
|
|
150
|
+
)
|
|
151
|
+
parser.add_argument(
|
|
152
|
+
"--log-level",
|
|
153
|
+
default=os.environ.get("SKILL_KERNEL_LOG_LEVEL", "info"),
|
|
154
|
+
)
|
|
155
|
+
args = parser.parse_args(argv)
|
|
156
|
+
|
|
157
|
+
# uvicorn import is here so module import (used by tests via
|
|
158
|
+
# `from plm_skill_kernel.server import build_app`) never pays the
|
|
159
|
+
# uvicorn startup cost.
|
|
160
|
+
import uvicorn
|
|
161
|
+
|
|
162
|
+
logger.info(
|
|
163
|
+
"plm-skill-kernel starting host=%s port=%s", args.host, args.port
|
|
164
|
+
)
|
|
165
|
+
uvicorn.run(
|
|
166
|
+
"plm_skill_kernel.server:app",
|
|
167
|
+
host=args.host,
|
|
168
|
+
port=args.port,
|
|
169
|
+
log_level=args.log_level,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
__all__ = ["DEFAULT_PORT", "app", "build_app", "_cli_entry"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Wave 2 starter skills — nested-by-skill-ID layout (Q-W2A-1).
|
|
2
|
+
|
|
3
|
+
Subpackages:
|
|
4
|
+
|
|
5
|
+
* ``cleansing.normalise`` — pure-function normalisation (L2).
|
|
6
|
+
* ``cleansing.dedupe`` — pure-function fuzzy dedupe (L2).
|
|
7
|
+
* ``bpmn.generate`` — thin proxy to ``agents/process_generator``
|
|
8
|
+
(L1 suggest-only). Per Q-W2A-2: the
|
|
9
|
+
cascade Gemini → Azure → Mock fallback
|
|
10
|
+
is preserved by reuse, NOT by harvest.
|
|
11
|
+
|
|
12
|
+
Per Decision #60, ``documents.parse`` + ``agents.suggest`` land in
|
|
13
|
+
Wave 3. The current ``__init__.py`` is intentionally empty — skills
|
|
14
|
+
register themselves at import time via the ``@skill`` decorator, and
|
|
15
|
+
the boot fan-out lives in :mod:`plm_skill_kernel.registry`.
|
|
16
|
+
"""
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Wave 3 Conv A — ``agents.*`` skill namespace.
|
|
2
|
+
|
|
3
|
+
Subpackages:
|
|
4
|
+
|
|
5
|
+
* ``agents.suggest`` — thin proxy to ``services.agent_analysis_service``.
|
|
6
|
+
L1 — surfaces 3 contextual modeling suggestions for a BPMN process or
|
|
7
|
+
for a free-text document. Caller renders the suggestions as one-tap
|
|
8
|
+
prompts in the Workbench / Studio reviewer UI.
|
|
9
|
+
|
|
10
|
+
The ``__init__.py`` is intentionally empty — skills register themselves at
|
|
11
|
+
import time via the ``@skill`` decorator; the boot fan-out lives in
|
|
12
|
+
:mod:`plm_skill_kernel.registry`.
|
|
13
|
+
"""
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""``agents.suggest`` / 1.0.0 / L1 — thin proxy to agent suggestion helpers.
|
|
2
|
+
|
|
3
|
+
Q-W3A-3 (Wave 3 Conv A) picked the thin-proxy shape: the skill imports +
|
|
4
|
+
delegates to ``services.agent_analysis_service`` (the legacy LLM-backed
|
|
5
|
+
suggestion helpers) at invocation time. Mirrors the ``bpmn.generate``
|
|
6
|
+
Decision #62 precedent — the V1 dispatch boundary is a lazy-import
|
|
7
|
+
contract; V1.1 will replace it with an MCP-bound out-of-process call.
|
|
8
|
+
|
|
9
|
+
V1 contract:
|
|
10
|
+
|
|
11
|
+
Input payload:
|
|
12
|
+
{
|
|
13
|
+
"mode": "agent" | "plan" | "from_text" | "from_text_plan",
|
|
14
|
+
"bpmn_xml": str | None, # required when mode in {agent, plan}
|
|
15
|
+
"text": str | None, # required when mode in {from_text, from_text_plan}
|
|
16
|
+
"doc_name": str | None, # required when mode in {from_text, from_text_plan}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
Output:
|
|
20
|
+
{
|
|
21
|
+
"mode": str,
|
|
22
|
+
"suggestions": [str, ...], # at most 3 suggestions
|
|
23
|
+
"count": int,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
L1 (suggest-only) — the skill returns a 3-suggestion list for the
|
|
27
|
+
caller's UI; it does NOT persist or commit. The caller (Studio /
|
|
28
|
+
Workbench reviewer UI) renders each suggestion as a one-tap prompt.
|
|
29
|
+
|
|
30
|
+
Architecture note: ``services.agent_analysis_service`` is a backend
|
|
31
|
+
module (``02_App/backend/services/``) NOT under ``plm_skill_kernel`` or
|
|
32
|
+
``plm_engine_core``. Importing it here creates a runtime dependency on
|
|
33
|
+
the backend Python path — the import is therefore done LAZILY inside
|
|
34
|
+
the function body so:
|
|
35
|
+
|
|
36
|
+
1. The plm-skill-kernel package itself can be imported without
|
|
37
|
+
requiring the backend on PYTHONPATH (e.g. a clean container image
|
|
38
|
+
of the kernel-only service).
|
|
39
|
+
2. Tests that don't exercise suggestion generation don't pay the
|
|
40
|
+
import cost (and don't require the backend deps to be installed).
|
|
41
|
+
|
|
42
|
+
The lazy-import pattern is the V1 dispatch-boundary contract; V1.1 will
|
|
43
|
+
replace it with an MCP-bound out-of-process call once the backend's
|
|
44
|
+
analysis entry points themselves migrate to an MCP server.
|
|
45
|
+
"""
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import logging
|
|
49
|
+
import sys
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from typing import Any, Dict, List
|
|
52
|
+
|
|
53
|
+
from plm_skill_kernel import SkillContext, skill
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Frozen V1 mode set — additive change only (no rename of existing values).
|
|
60
|
+
_VALID_MODES = frozenset({
|
|
61
|
+
"agent",
|
|
62
|
+
"plan",
|
|
63
|
+
"from_text",
|
|
64
|
+
"from_text_plan",
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _ensure_backend_on_path() -> None:
|
|
69
|
+
"""Best-effort: add ``02_App/backend`` to ``sys.path`` if it isn't.
|
|
70
|
+
|
|
71
|
+
Same pattern as :mod:`plm_skill_kernel.skills.bpmn.generate` — kept
|
|
72
|
+
duplicated rather than extracted to a helper so each skill stays
|
|
73
|
+
self-contained at import time (no cross-skill coupling at the SDK
|
|
74
|
+
registry layer).
|
|
75
|
+
"""
|
|
76
|
+
here = Path(__file__).resolve()
|
|
77
|
+
# plm_skill_kernel/skills/agents/suggest.py → 4× parents = plm-skill-kernel/
|
|
78
|
+
# 5× parents = 02_App/.
|
|
79
|
+
candidate = here.parents[4] / "backend"
|
|
80
|
+
if candidate.exists():
|
|
81
|
+
candidate_str = str(candidate)
|
|
82
|
+
if candidate_str not in sys.path:
|
|
83
|
+
sys.path.insert(0, candidate_str)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@skill(
|
|
87
|
+
id="agents.suggest",
|
|
88
|
+
version="1.0.0",
|
|
89
|
+
description=(
|
|
90
|
+
"Generate 3 contextual modelling-suggestion prompts from either "
|
|
91
|
+
"a BPMN process or a free-text document. Suggest-only (L1) — "
|
|
92
|
+
"caller renders the result as one-tap prompts. Wraps "
|
|
93
|
+
"services.agent_analysis_service helpers."
|
|
94
|
+
),
|
|
95
|
+
min_autonomy="L1",
|
|
96
|
+
streaming=False,
|
|
97
|
+
hitl_required=False,
|
|
98
|
+
)
|
|
99
|
+
async def agents_suggest(
|
|
100
|
+
payload: Dict[str, Any], ctx: SkillContext
|
|
101
|
+
) -> Dict[str, Any]:
|
|
102
|
+
"""Thin proxy to ``services.agent_analysis_service.generate_suggestions*``.
|
|
103
|
+
|
|
104
|
+
Validates the V1 contract surface (``mode`` is required + the
|
|
105
|
+
corresponding ``bpmn_xml`` / ``text`` payload is the right shape)
|
|
106
|
+
before reaching the backend; once the proxy fires, the legacy LLM
|
|
107
|
+
helpers own the rest.
|
|
108
|
+
"""
|
|
109
|
+
mode = payload.get("mode")
|
|
110
|
+
if not isinstance(mode, str) or mode not in _VALID_MODES:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"agents.suggest: payload['mode'] must be one of "
|
|
113
|
+
f"{sorted(_VALID_MODES)}; got {mode!r}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if mode in {"agent", "plan"}:
|
|
117
|
+
bpmn_xml = payload.get("bpmn_xml")
|
|
118
|
+
if not isinstance(bpmn_xml, str) or not bpmn_xml:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
"agents.suggest: payload['bpmn_xml'] must be a non-empty "
|
|
121
|
+
"string when mode is 'agent' or 'plan'"
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
bpmn_xml = None # not used in from_text* modes
|
|
125
|
+
|
|
126
|
+
if mode in {"from_text", "from_text_plan"}:
|
|
127
|
+
text = payload.get("text")
|
|
128
|
+
if not isinstance(text, str):
|
|
129
|
+
raise ValueError(
|
|
130
|
+
"agents.suggest: payload['text'] must be a string when "
|
|
131
|
+
"mode is 'from_text' or 'from_text_plan'"
|
|
132
|
+
)
|
|
133
|
+
doc_name = payload.get("doc_name")
|
|
134
|
+
if not isinstance(doc_name, str) or not doc_name:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
"agents.suggest: payload['doc_name'] must be a non-empty "
|
|
137
|
+
"string when mode is 'from_text' or 'from_text_plan'"
|
|
138
|
+
)
|
|
139
|
+
else:
|
|
140
|
+
text = None
|
|
141
|
+
doc_name = None
|
|
142
|
+
|
|
143
|
+
_ensure_backend_on_path()
|
|
144
|
+
try:
|
|
145
|
+
from studio_backend.services.agent_analysis_service import ( # type: ignore[import-not-found]
|
|
146
|
+
generate_suggestions,
|
|
147
|
+
generate_suggestions_for_plan,
|
|
148
|
+
generate_suggestions_from_text,
|
|
149
|
+
)
|
|
150
|
+
except ImportError as exc:
|
|
151
|
+
raise RuntimeError(
|
|
152
|
+
"agents.suggest: backend module `services.agent_analysis_service` "
|
|
153
|
+
"is not importable from this kernel process. The V1 thin-"
|
|
154
|
+
"proxy shape requires the backend on PYTHONPATH; install "
|
|
155
|
+
"the backend editable or run via 02_App/run.py."
|
|
156
|
+
) from exc
|
|
157
|
+
|
|
158
|
+
logger.info(
|
|
159
|
+
"agents.suggest dispatching mode=%s tenant=%s run_id=%s",
|
|
160
|
+
mode,
|
|
161
|
+
ctx.tenant_id,
|
|
162
|
+
ctx.run_id,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
suggestions: List[str]
|
|
166
|
+
if mode == "agent":
|
|
167
|
+
result = await generate_suggestions(bpmn_xml or "", mode="agent")
|
|
168
|
+
suggestions = list(result) if isinstance(result, list) else []
|
|
169
|
+
elif mode == "plan":
|
|
170
|
+
result = await generate_suggestions(bpmn_xml or "", mode="plan")
|
|
171
|
+
suggestions = list(result) if isinstance(result, list) else []
|
|
172
|
+
elif mode == "from_text":
|
|
173
|
+
result = await generate_suggestions_from_text(text or "", doc_name or "")
|
|
174
|
+
suggestions = list(result) if isinstance(result, list) else []
|
|
175
|
+
else: # from_text_plan
|
|
176
|
+
result = await generate_suggestions_for_plan(text or "", doc_name or "")
|
|
177
|
+
suggestions = list(result) if isinstance(result, list) else []
|
|
178
|
+
|
|
179
|
+
# V1 contract: stringify defensively + cap at 3 (matches the legacy
|
|
180
|
+
# helpers' contract). The legacy helpers already enforce the cap;
|
|
181
|
+
# we re-apply here so a future helper drift doesn't widen this skill.
|
|
182
|
+
suggestions = [str(s) for s in suggestions if s][:3]
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
"mode": mode,
|
|
186
|
+
"suggestions": suggestions,
|
|
187
|
+
"count": len(suggestions),
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
__all__ = ["agents_suggest"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""BPMN skills namespace — ``bpmn.generate`` (Wave 2 starter, L1).
|
|
2
|
+
|
|
3
|
+
Per Q-W2A-2 (Conv N close), ``bpmn.generate`` is a thin proxy to the
|
|
4
|
+
existing ``agents/process_generator.generate_process`` cascade. The
|
|
5
|
+
cascade Gemini → Azure → Mock is the only legitimate fallback per
|
|
6
|
+
CLAUDE.md "Strict model dispatch" exception; preserving it via reuse
|
|
7
|
+
keeps the cascade in one place rather than duplicating it inside the
|
|
8
|
+
Skill body.
|
|
9
|
+
"""
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""``bpmn.generate`` / 1.0.0 / L1 — thin proxy to the BPMN cascade.
|
|
2
|
+
|
|
3
|
+
Q-W2A-2 (Conv N close) picked the thin-proxy shape: the skill imports
|
|
4
|
+
+ delegates to ``agents/process_generator.generate_process`` at
|
|
5
|
+
invocation time. The cascade Gemini → Azure → Mock is the only
|
|
6
|
+
legitimate fallback per CLAUDE.md "Strict model dispatch" exception;
|
|
7
|
+
proxying preserves it without duplication.
|
|
8
|
+
|
|
9
|
+
V1 contract:
|
|
10
|
+
|
|
11
|
+
Input payload:
|
|
12
|
+
{
|
|
13
|
+
"content": str, # required — CSV / text content
|
|
14
|
+
"source_type": str, # required — "mining-csv" or "text"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
Output:
|
|
18
|
+
{
|
|
19
|
+
"name": str,
|
|
20
|
+
"description": str,
|
|
21
|
+
"bpmnXml": str,
|
|
22
|
+
"miningMetrics":[ {...}, ... ],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
L1 (suggest-only) — the skill returns BPMN XML for the caller to
|
|
26
|
+
review; it does NOT persist or commit.
|
|
27
|
+
|
|
28
|
+
Architecture note: ``agents.process_generator`` is a backend module
|
|
29
|
+
(``02_App/backend/agents/``) NOT under ``plm_skill_kernel`` or
|
|
30
|
+
``plm_engine_core``. Importing it here creates a runtime dependency
|
|
31
|
+
on the backend Python path — the import is therefore done LAZILY
|
|
32
|
+
inside the function body so:
|
|
33
|
+
|
|
34
|
+
1. The plm-skill-kernel package itself can be imported without
|
|
35
|
+
requiring the backend on PYTHONPATH (e.g. a clean container
|
|
36
|
+
image of the kernel-only service).
|
|
37
|
+
2. Tests that don't exercise the BPMN cascade do not pay the
|
|
38
|
+
import cost (and don't require the backend deps to be installed).
|
|
39
|
+
|
|
40
|
+
The lazy-import pattern is the V1 dispatch-boundary contract; V1.1
|
|
41
|
+
will replace it with an MCP-bound out-of-process call once the
|
|
42
|
+
backend's BPMN entry point itself migrates to an MCP server.
|
|
43
|
+
"""
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import logging
|
|
47
|
+
import sys
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
from typing import Any, Dict
|
|
50
|
+
|
|
51
|
+
from plm_skill_kernel import SkillContext, skill
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ensure_backend_on_path() -> None:
|
|
58
|
+
"""Best-effort: add ``02_App/backend`` to ``sys.path`` if it isn't.
|
|
59
|
+
|
|
60
|
+
V1 reality: the kernel runs out of the same monorepo as the
|
|
61
|
+
backend; ``02_App/run.py`` is the canonical dev launcher and it
|
|
62
|
+
does NOT yet add backend/ to PYTHONPATH for the kernel process.
|
|
63
|
+
The lazy fix-up keeps a kernel-only deployment viable (it'll fail
|
|
64
|
+
fast at the import site below with a clear error message rather
|
|
65
|
+
than a confusing ImportError) AND keeps the dev path working.
|
|
66
|
+
|
|
67
|
+
Wave 2 Conv B + the carve will replace this with a proper backend
|
|
68
|
+
entry-point package if the proxy survives that long.
|
|
69
|
+
"""
|
|
70
|
+
here = Path(__file__).resolve()
|
|
71
|
+
# plm_skill_kernel/skills/bpmn/generate.py → 4× parents = plm-skill-kernel/
|
|
72
|
+
# 5× parents = 02_App/.
|
|
73
|
+
candidate = here.parents[4] / "backend"
|
|
74
|
+
if candidate.exists():
|
|
75
|
+
candidate_str = str(candidate)
|
|
76
|
+
if candidate_str not in sys.path:
|
|
77
|
+
sys.path.insert(0, candidate_str)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@skill(
|
|
81
|
+
id="bpmn.generate",
|
|
82
|
+
version="1.0.0",
|
|
83
|
+
description=(
|
|
84
|
+
"Generate a BPMN 2.0 process from CSV mining data or natural "
|
|
85
|
+
"language. Suggest-only (L1) — caller reviews + commits. "
|
|
86
|
+
"Wraps the legitimate Gemini → Azure → Mock cascade."
|
|
87
|
+
),
|
|
88
|
+
min_autonomy="L1",
|
|
89
|
+
streaming=False,
|
|
90
|
+
hitl_required=False,
|
|
91
|
+
)
|
|
92
|
+
async def bpmn_generate(
|
|
93
|
+
payload: Dict[str, Any], ctx: SkillContext
|
|
94
|
+
) -> Dict[str, Any]:
|
|
95
|
+
"""Thin proxy to ``agents.process_generator.generate_process``.
|
|
96
|
+
|
|
97
|
+
Validates the V1 contract surface (``content`` + ``source_type``
|
|
98
|
+
are required + must be the right shapes) before reaching the
|
|
99
|
+
backend; once the proxy fires, the cascade owns the rest.
|
|
100
|
+
"""
|
|
101
|
+
content = payload.get("content")
|
|
102
|
+
source_type = payload.get("source_type")
|
|
103
|
+
if not isinstance(content, str) or not content:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
"bpmn.generate: payload['content'] must be a non-empty string"
|
|
106
|
+
)
|
|
107
|
+
if not isinstance(source_type, str) or not source_type:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"bpmn.generate: payload['source_type'] must be a non-empty string"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
_ensure_backend_on_path()
|
|
113
|
+
try:
|
|
114
|
+
from plm_skill_packages.process_generator import generate_process # type: ignore[import-not-found]
|
|
115
|
+
except ImportError as exc:
|
|
116
|
+
raise RuntimeError(
|
|
117
|
+
"bpmn.generate: backend module `agents.process_generator` "
|
|
118
|
+
"is not importable from this kernel process. The V1 thin-"
|
|
119
|
+
"proxy shape requires the backend on PYTHONPATH; install "
|
|
120
|
+
"the backend editable or run via 02_App/run.py."
|
|
121
|
+
) from exc
|
|
122
|
+
|
|
123
|
+
logger.info(
|
|
124
|
+
"bpmn.generate dispatching content_chars=%s source_type=%s "
|
|
125
|
+
"tenant=%s run_id=%s",
|
|
126
|
+
len(content),
|
|
127
|
+
source_type,
|
|
128
|
+
ctx.tenant_id,
|
|
129
|
+
ctx.run_id,
|
|
130
|
+
)
|
|
131
|
+
result = await generate_process(content, source_type)
|
|
132
|
+
|
|
133
|
+
# ``generate_process`` returns a dict with ``name``, ``description``,
|
|
134
|
+
# ``bpmnXml``, ``miningMetrics``. V1 contract is the same shape;
|
|
135
|
+
# pass-through unchanged. Defensive: if the cascade hands back an
|
|
136
|
+
# unexpected shape, surface a structured error rather than letting
|
|
137
|
+
# the dispatcher's generic dict-check fire (which loses context).
|
|
138
|
+
if not isinstance(result, dict):
|
|
139
|
+
raise RuntimeError(
|
|
140
|
+
f"bpmn.generate: process_generator returned {type(result).__name__}; "
|
|
141
|
+
"expected dict"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
__all__ = ["bpmn_generate"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Cleansing skills namespace — ``cleansing.normalise`` + ``cleansing.dedupe``.
|
|
2
|
+
|
|
3
|
+
Both skills are pure-function in V1: no DB reach-in, no LLM call,
|
|
4
|
+
no side effects. The harvest sources (``services/cleansing_service.py``
|
|
5
|
+
+ ``agents/process_generator`` normalisation surface) carry heavy
|
|
6
|
+
DB + service coupling that does NOT belong inside a Skill body — V1
|
|
7
|
+
ships the canonical normalisation/dedupe primitives at the Kernel
|
|
8
|
+
layer; the backend cleansing service can call into the Kernel via
|
|
9
|
+
the V1.1 HTTP loopback once the carve at Wave 2 Conv B lands.
|
|
10
|
+
"""
|