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.
Files changed (38) hide show
  1. plm_skill_kernel/__init__.py +33 -0
  2. plm_skill_kernel/__main__.py +13 -0
  3. plm_skill_kernel/dispatcher.py +141 -0
  4. plm_skill_kernel/registry.py +80 -0
  5. plm_skill_kernel/sdk/__init__.py +29 -0
  6. plm_skill_kernel/sdk/_registry.py +111 -0
  7. plm_skill_kernel/sdk/context.py +66 -0
  8. plm_skill_kernel/sdk/decorator.py +147 -0
  9. plm_skill_kernel/sdk/types.py +86 -0
  10. plm_skill_kernel/server.py +173 -0
  11. plm_skill_kernel/skills/__init__.py +16 -0
  12. plm_skill_kernel/skills/agents/__init__.py +13 -0
  13. plm_skill_kernel/skills/agents/suggest.py +191 -0
  14. plm_skill_kernel/skills/bpmn/__init__.py +9 -0
  15. plm_skill_kernel/skills/bpmn/generate.py +147 -0
  16. plm_skill_kernel/skills/cleansing/__init__.py +10 -0
  17. plm_skill_kernel/skills/cleansing/dedupe.py +162 -0
  18. plm_skill_kernel/skills/cleansing/normalise.py +178 -0
  19. plm_skill_kernel/skills/documents/__init__.py +12 -0
  20. plm_skill_kernel/skills/documents/parse.py +202 -0
  21. plm_skill_kernel/skills/knowledge/__init__.py +32 -0
  22. plm_skill_kernel/skills/knowledge/_v1_corpus.py +153 -0
  23. plm_skill_kernel/skills/knowledge/get_record.py +102 -0
  24. plm_skill_kernel/skills/knowledge/resolve.py +143 -0
  25. plm_skill_kernel/skills/knowledge/retrieve.py +143 -0
  26. plm_skill_kernel/skills/knowledge/search.py +161 -0
  27. plm_skill_kernel/skills/knowledge/validate.py +222 -0
  28. plm_skill_kernel/skills_legacy/__init__.py +0 -0
  29. plm_skill_kernel/skills_legacy/knowledge_pack_loader.py +17 -0
  30. plm_skill_kernel/skills_legacy/knowledge_search_engine.py +25 -0
  31. plm_skill_kernel/skills_legacy/plm_skill_registry.py +573 -0
  32. plm_skill_kernel/skills_legacy/plm_tool_definitions.py +44 -0
  33. plm_skill_kernel/skills_legacy/skill_orchestrator.py +163 -0
  34. plm_skill_kernel-1.0.0.dist-info/METADATA +135 -0
  35. plm_skill_kernel-1.0.0.dist-info/RECORD +38 -0
  36. plm_skill_kernel-1.0.0.dist-info/WHEEL +5 -0
  37. plm_skill_kernel-1.0.0.dist-info/entry_points.txt +2 -0
  38. 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
+ """