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,162 @@
1
+ """``cleansing.dedupe`` / 1.0.0 / L2 — pure-function record dedupe.
2
+
3
+ V1 dedupe primitives (no DB, no LLM):
4
+
5
+ * **Exact**: record key matches verbatim → duplicates collapse.
6
+ * **Normalised**: apply the same NFKC + whitespace collapse + case
7
+ fold pipeline as ``cleansing.normalise`` to the key, then compare.
8
+
9
+ The skill picks one canonical representative per group (the FIRST
10
+ occurrence; index order is preserved), reports the duplicate clusters,
11
+ and returns a deduped list. No fuzzy / Levenshtein in V1 — that's a
12
+ V1.1 expansion (would need a tunable threshold + a more expensive
13
+ pairwise compute path that doesn't fit a pure synchronous skill body).
14
+
15
+ Input contract (``payload``):
16
+
17
+ {
18
+ "records": [str | dict, ...], # required
19
+ "key": str | None, # required for dicts
20
+ "mode": "exact" | "normalised", # default "normalised"
21
+ }
22
+
23
+ Output:
24
+
25
+ {
26
+ "deduped": [str | dict, ...],
27
+ "duplicates": [
28
+ {"key": <key>, "count": N, "indices": [i, j, ...]},
29
+ ...
30
+ ],
31
+ "stats": {"records": N, "unique": K, "duplicates": D},
32
+ }
33
+
34
+ Harvested from the dedupe pass that runs on imported product-structure
35
+ nodes in ``services/cleansing_service`` (label-collision detection at
36
+ import) — that backend path can migrate to invoking this skill after
37
+ the Wave 2 Conv B carve.
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import re
42
+ import unicodedata
43
+ from collections import OrderedDict
44
+ from typing import Any, Dict, List, Optional
45
+
46
+ from plm_skill_kernel import SkillContext, skill
47
+
48
+
49
+ _WS_RE = re.compile(r"\s+")
50
+ _VALID_MODES = frozenset({"exact", "normalised"})
51
+
52
+
53
+ def _normalise_key(raw: Any) -> str:
54
+ """V1 normalised-key shape — same primitives as cleansing.normalise.
55
+
56
+ Kept inline rather than imported from the sibling module so the
57
+ skill is self-contained at import time (no cross-skill coupling
58
+ in the SDK registry).
59
+ """
60
+ if not isinstance(raw, str):
61
+ raw = str(raw)
62
+ val = unicodedata.normalize("NFKC", raw)
63
+ val = _WS_RE.sub(" ", val).strip().lower()
64
+ return val
65
+
66
+
67
+ def _extract_key(
68
+ record: Any, key: Optional[str]
69
+ ) -> Any:
70
+ """Pull the dedup key from a record.
71
+
72
+ Strings dedupe by themselves; dicts dedupe by ``record[key]``.
73
+ ``None`` keys (missing field on a dict) are returned as-is and will
74
+ only collide with other ``None``-keyed records.
75
+ """
76
+ if isinstance(record, str):
77
+ return record
78
+ if isinstance(record, dict):
79
+ if key is None:
80
+ raise ValueError(
81
+ "cleansing.dedupe: payload['key'] is required when "
82
+ "records are dicts"
83
+ )
84
+ return record.get(key)
85
+ return record
86
+
87
+
88
+ @skill(
89
+ id="cleansing.dedupe",
90
+ version="1.0.0",
91
+ description=(
92
+ "Pure-function dedupe — exact or normalised-key matching. "
93
+ "Picks the first occurrence as canonical; reports clusters."
94
+ ),
95
+ min_autonomy="L2",
96
+ streaming=False,
97
+ hitl_required=False,
98
+ )
99
+ async def cleansing_dedupe(
100
+ payload: Dict[str, Any], ctx: SkillContext
101
+ ) -> Dict[str, Any]:
102
+ """Dedupe a list of records by exact or normalised key."""
103
+ records = payload.get("records")
104
+ if not isinstance(records, list):
105
+ raise ValueError(
106
+ "cleansing.dedupe: payload['records'] must be a list"
107
+ )
108
+
109
+ mode = payload.get("mode", "normalised")
110
+ if mode not in _VALID_MODES:
111
+ raise ValueError(
112
+ f"cleansing.dedupe: payload['mode'] must be one of "
113
+ f"{sorted(_VALID_MODES)}; got {mode!r}"
114
+ )
115
+
116
+ key = payload.get("key")
117
+
118
+ # OrderedDict preserves first-seen order — that becomes the
119
+ # canonical pick-order (V1 spec: first occurrence wins).
120
+ canonical: "OrderedDict[Any, int]" = OrderedDict()
121
+ clusters: Dict[Any, List[int]] = {}
122
+
123
+ for idx, record in enumerate(records):
124
+ try:
125
+ raw_key = _extract_key(record, key)
126
+ except ValueError:
127
+ raise
128
+
129
+ cluster_key = (
130
+ _normalise_key(raw_key) if mode == "normalised" else raw_key
131
+ )
132
+
133
+ if cluster_key not in canonical:
134
+ canonical[cluster_key] = idx
135
+ clusters[cluster_key] = [idx]
136
+ else:
137
+ clusters[cluster_key].append(idx)
138
+
139
+ deduped = [records[i] for i in canonical.values()]
140
+ duplicate_clusters = [
141
+ {
142
+ "key": cluster_key,
143
+ "count": len(indices),
144
+ "indices": indices,
145
+ }
146
+ for cluster_key, indices in clusters.items()
147
+ if len(indices) > 1
148
+ ]
149
+ duplicate_total = sum(c["count"] - 1 for c in duplicate_clusters)
150
+
151
+ return {
152
+ "deduped": deduped,
153
+ "duplicates": duplicate_clusters,
154
+ "stats": {
155
+ "records": len(records),
156
+ "unique": len(deduped),
157
+ "duplicates": duplicate_total,
158
+ },
159
+ }
160
+
161
+
162
+ __all__ = ["cleansing_dedupe"]
@@ -0,0 +1,178 @@
1
+ """``cleansing.normalise`` / 1.0.0 / L2 — pure-function record normaliser.
2
+
3
+ V1 normalisation primitives (idempotent, no side effects):
4
+
5
+ * Unicode NFKC normalisation — collapses compatibility forms.
6
+ * Whitespace collapse — strip + replace runs of whitespace with a
7
+ single space.
8
+ * Optional case fold — caller passes ``case="lower"`` / ``"upper"``
9
+ / ``"none"``; default is ``"lower"``.
10
+ * Optional punctuation strip — caller passes ``strip_punctuation=True``
11
+ to remove ``.,;:!?-_/\\`` and matching brackets; default ``False``.
12
+
13
+ Input contract (``payload``):
14
+
15
+ {
16
+ "records": [str | dict, ...], # required
17
+ "fields": [str, ...] | None, # if records=dicts
18
+ "case": "lower" | "upper" | "none", # default "lower"
19
+ "strip_punctuation": bool, # default False
20
+ }
21
+
22
+ Output:
23
+
24
+ {
25
+ "normalised": [str | dict, ...], # mirrors input shape
26
+ "stats": {"records": N, "changed": K},
27
+ }
28
+
29
+ The skill is harvested from the CSV-mining normalisation step in
30
+ ``agents/process_generator`` + the label cleansing applied in
31
+ ``services/cleansing_service``. Both call sites can migrate to the
32
+ Skill once the V1.1 HTTP loopback lands (Wave 2 Conv B).
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import re
37
+ import unicodedata
38
+ from typing import Any, Dict, List, Optional, Tuple
39
+
40
+ from plm_skill_kernel import SkillContext, skill
41
+
42
+
43
+ # Punctuation set per V1 spec — compact regex compiled once per process.
44
+ _PUNCT_RE = re.compile(r"[.,;:!?\-_/\\\(\)\[\]\{\}]+")
45
+ _WS_RE = re.compile(r"\s+")
46
+
47
+
48
+ _VALID_CASES = frozenset({"lower", "upper", "none"})
49
+
50
+
51
+ def _normalise_string(
52
+ raw: str, *, case: str, strip_punctuation: bool
53
+ ) -> str:
54
+ """Apply the V1 normalisation pipeline to a single string."""
55
+ if not isinstance(raw, str):
56
+ raw = str(raw)
57
+ # Step 1 — Unicode NFKC. Collapses compat-forms (full-width digits,
58
+ # ligatures, etc.) into their canonical equivalents.
59
+ val = unicodedata.normalize("NFKC", raw)
60
+ # Step 2 — punctuation (optional, before whitespace collapse so
61
+ # punctuation-induced spacing collapses cleanly).
62
+ if strip_punctuation:
63
+ val = _PUNCT_RE.sub(" ", val)
64
+ # Step 3 — whitespace collapse.
65
+ val = _WS_RE.sub(" ", val).strip()
66
+ # Step 4 — case fold (default lower for downstream-comparison
67
+ # stability; punctuation/whitespace-only inputs collapse to "").
68
+ if case == "lower":
69
+ val = val.lower()
70
+ elif case == "upper":
71
+ val = val.upper()
72
+ return val
73
+
74
+
75
+ def _normalise_dict(
76
+ record: Dict[str, Any],
77
+ *,
78
+ fields: Optional[List[str]],
79
+ case: str,
80
+ strip_punctuation: bool,
81
+ ) -> Tuple[Dict[str, Any], bool]:
82
+ """Apply normalisation to selected fields; non-selected fields pass through."""
83
+ out: Dict[str, Any] = dict(record)
84
+ changed = False
85
+ target_fields = fields if fields is not None else list(record.keys())
86
+ for field in target_fields:
87
+ if field not in out:
88
+ continue
89
+ original = out[field]
90
+ if not isinstance(original, str):
91
+ continue
92
+ normalised = _normalise_string(
93
+ original, case=case, strip_punctuation=strip_punctuation
94
+ )
95
+ if normalised != original:
96
+ changed = True
97
+ out[field] = normalised
98
+ return out, changed
99
+
100
+
101
+ @skill(
102
+ id="cleansing.normalise",
103
+ version="1.0.0",
104
+ description=(
105
+ "Pure-function normalisation of records (Unicode NFKC, "
106
+ "whitespace collapse, case fold, optional punctuation strip)."
107
+ ),
108
+ min_autonomy="L2",
109
+ streaming=False,
110
+ hitl_required=False,
111
+ )
112
+ async def cleansing_normalise(
113
+ payload: Dict[str, Any], ctx: SkillContext
114
+ ) -> Dict[str, Any]:
115
+ """Normalise a list of records.
116
+
117
+ Records can be plain strings or dicts. When dicts, the optional
118
+ ``fields`` array selects which keys to normalise; default = all
119
+ string-valued keys.
120
+ """
121
+ records = payload.get("records")
122
+ if not isinstance(records, list):
123
+ raise ValueError(
124
+ "cleansing.normalise: payload['records'] must be a list"
125
+ )
126
+ fields = payload.get("fields")
127
+ if fields is not None and not isinstance(fields, list):
128
+ raise ValueError(
129
+ "cleansing.normalise: payload['fields'] must be a list when set"
130
+ )
131
+
132
+ case_raw = payload.get("case", "lower")
133
+ if case_raw not in _VALID_CASES:
134
+ raise ValueError(
135
+ f"cleansing.normalise: payload['case'] must be one of "
136
+ f"{sorted(_VALID_CASES)}; got {case_raw!r}"
137
+ )
138
+ case: str = case_raw
139
+
140
+ strip_punctuation = bool(payload.get("strip_punctuation", False))
141
+
142
+ normalised: List[Any] = []
143
+ changed_count = 0
144
+ for record in records:
145
+ if isinstance(record, str):
146
+ new_val = _normalise_string(
147
+ record, case=case, strip_punctuation=strip_punctuation
148
+ )
149
+ if new_val != record:
150
+ changed_count += 1
151
+ normalised.append(new_val)
152
+ elif isinstance(record, dict):
153
+ new_record, changed = _normalise_dict(
154
+ record,
155
+ fields=fields,
156
+ case=case,
157
+ strip_punctuation=strip_punctuation,
158
+ )
159
+ if changed:
160
+ changed_count += 1
161
+ normalised.append(new_record)
162
+ else:
163
+ # Unknown shape — pass through untouched. V1 contract is
164
+ # str-or-dict; arbitrary shapes (numbers, None) flow
165
+ # without normalisation rather than raising, so callers
166
+ # can pass mixed-shape lists without pre-filtering.
167
+ normalised.append(record)
168
+
169
+ return {
170
+ "normalised": normalised,
171
+ "stats": {
172
+ "records": len(records),
173
+ "changed": changed_count,
174
+ },
175
+ }
176
+
177
+
178
+ __all__ = ["cleansing_normalise"]
@@ -0,0 +1,12 @@
1
+ """Wave 3 Conv A — ``documents.*`` skill namespace.
2
+
3
+ Subpackages:
4
+
5
+ * ``documents.parse`` — thin proxy to ``services.agent_document_handler``
6
+ (W2-CARVE-3 façade). L1 — classifies + extracts content from caller-
7
+ supplied attachments without persisting state.
8
+
9
+ The ``__init__.py`` is intentionally empty — skills register themselves at
10
+ import time via the ``@skill`` decorator; the boot fan-out lives in
11
+ :mod:`plm_skill_kernel.registry`.
12
+ """
@@ -0,0 +1,202 @@
1
+ """``documents.parse`` / 1.0.0 / L1 — thin proxy to the W2-CARVE-3 façade.
2
+
3
+ Q-W3A-2 (Wave 3 Conv A) picked the thin-proxy shape: the skill imports +
4
+ delegates to ``services.agent_document_handler`` (the Wave 2 Conv C
5
+ W2-CARVE-3 façade that froze the V1 input shape) at invocation time.
6
+ Mirrors the ``bpmn.generate`` Decision #62 precedent — the V1 dispatch
7
+ boundary is a lazy-import contract; V1.1 will replace it with an MCP-
8
+ bound out-of-process call.
9
+
10
+ V1 contract:
11
+
12
+ Input payload:
13
+ {
14
+ "filename": str, # required
15
+ "content_type": str | None, # optional
16
+ "pdf_bytes_b64": str | None, # optional, base64
17
+ "text": str | None, # optional
18
+ }
19
+
20
+ At least one of ``pdf_bytes_b64`` / ``text`` MUST be present
21
+ unless the caller only wants classification metadata.
22
+
23
+ Output:
24
+ {
25
+ "filename": str,
26
+ "kind": "pdf" | "text" | "viewer_3d"
27
+ | "binary" | "unknown",
28
+ "extension": str,
29
+ "use_3d_viewer": bool,
30
+ "content": str, # extracted text (or "" when N/A)
31
+ "extracted": bool, # True if PDF/text payload yielded content
32
+ }
33
+
34
+ L1 (suggest-only) — the skill returns the parse result for the caller
35
+ to use; it does NOT persist or commit. The caller is responsible for
36
+ attaching the parse result to a CleansingDocument or Workbench upload.
37
+
38
+ Architecture note: ``services.agent_document_handler`` is a backend
39
+ module (``02_App/backend/services/``) NOT under ``plm_skill_kernel`` or
40
+ ``plm_engine_core``. Importing it here creates a runtime dependency on
41
+ the backend Python path — the import is therefore done LAZILY inside
42
+ the function body so:
43
+
44
+ 1. The plm-skill-kernel package itself can be imported without
45
+ requiring the backend on PYTHONPATH (e.g. a clean container image
46
+ of the kernel-only service).
47
+ 2. Tests that don't exercise document parsing don't pay the import
48
+ cost (and don't require the backend deps to be installed).
49
+
50
+ The lazy-import pattern is the V1 dispatch-boundary contract; V1.1 will
51
+ replace it with an MCP-bound out-of-process call once the backend's
52
+ parse entry point itself migrates to an MCP server.
53
+ """
54
+ from __future__ import annotations
55
+
56
+ import base64
57
+ import binascii
58
+ import logging
59
+ import sys
60
+ from pathlib import Path
61
+ from typing import Any, Dict, Optional
62
+
63
+ from plm_skill_kernel import SkillContext, skill
64
+
65
+
66
+ logger = logging.getLogger(__name__)
67
+
68
+
69
+ def _ensure_backend_on_path() -> None:
70
+ """Best-effort: add ``02_App/backend`` to ``sys.path`` if it isn't.
71
+
72
+ Same pattern as :mod:`plm_skill_kernel.skills.bpmn.generate` — kept
73
+ duplicated rather than extracted to a helper so each skill stays
74
+ self-contained at import time (no cross-skill coupling at the SDK
75
+ registry layer).
76
+ """
77
+ here = Path(__file__).resolve()
78
+ # plm_skill_kernel/skills/documents/parse.py → 4× parents = plm-skill-kernel/
79
+ # 5× parents = 02_App/.
80
+ candidate = here.parents[4] / "backend"
81
+ if candidate.exists():
82
+ candidate_str = str(candidate)
83
+ if candidate_str not in sys.path:
84
+ sys.path.insert(0, candidate_str)
85
+
86
+
87
+ def _decode_pdf_bytes(b64: str) -> bytes:
88
+ """Decode a base64-encoded PDF payload.
89
+
90
+ Raises ValueError on malformed input — V1 contract surfaces the
91
+ error as a structured envelope at the dispatcher boundary.
92
+ """
93
+ try:
94
+ return base64.b64decode(b64, validate=True)
95
+ except (binascii.Error, ValueError) as exc:
96
+ raise ValueError(
97
+ f"documents.parse: payload['pdf_bytes_b64'] is not valid base64 ({exc})"
98
+ ) from exc
99
+
100
+
101
+ @skill(
102
+ id="documents.parse",
103
+ version="1.0.0",
104
+ description=(
105
+ "Classify + extract content from a caller-supplied attachment "
106
+ "(PDF text extraction, 3D-viewer hint, text passthrough). "
107
+ "Suggest-only (L1) — caller persists the result. Wraps the "
108
+ "Wave 2 Conv C W2-CARVE-3 façade."
109
+ ),
110
+ min_autonomy="L1",
111
+ streaming=False,
112
+ hitl_required=False,
113
+ )
114
+ async def documents_parse(
115
+ payload: Dict[str, Any], ctx: SkillContext
116
+ ) -> Dict[str, Any]:
117
+ """Thin proxy to ``services.agent_document_handler``.
118
+
119
+ Validates the V1 contract surface (``filename`` is required +
120
+ ``pdf_bytes_b64`` / ``text`` shapes are validated when present)
121
+ before reaching the backend; once the proxy fires, the W2-CARVE-3
122
+ façade owns classification + extraction.
123
+ """
124
+ filename = payload.get("filename")
125
+ if not isinstance(filename, str) or not filename:
126
+ raise ValueError(
127
+ "documents.parse: payload['filename'] must be a non-empty string"
128
+ )
129
+
130
+ content_type_raw = payload.get("content_type")
131
+ if content_type_raw is not None and not isinstance(content_type_raw, str):
132
+ raise ValueError(
133
+ "documents.parse: payload['content_type'] must be a string when set"
134
+ )
135
+ content_type: Optional[str] = content_type_raw
136
+
137
+ pdf_b64 = payload.get("pdf_bytes_b64")
138
+ if pdf_b64 is not None and not isinstance(pdf_b64, str):
139
+ raise ValueError(
140
+ "documents.parse: payload['pdf_bytes_b64'] must be a string when set"
141
+ )
142
+
143
+ text_payload = payload.get("text")
144
+ if text_payload is not None and not isinstance(text_payload, str):
145
+ raise ValueError(
146
+ "documents.parse: payload['text'] must be a string when set"
147
+ )
148
+
149
+ _ensure_backend_on_path()
150
+ try:
151
+ from studio_backend.services.agent_document_handler import ( # type: ignore[import-not-found]
152
+ AttachmentKind,
153
+ classify_attachment,
154
+ extract_pdf_text,
155
+ )
156
+ except ImportError as exc:
157
+ raise RuntimeError(
158
+ "documents.parse: backend module `services.agent_document_handler` "
159
+ "is not importable from this kernel process. The V1 thin-"
160
+ "proxy shape requires the backend on PYTHONPATH; install "
161
+ "the backend editable or run via 02_App/run.py."
162
+ ) from exc
163
+
164
+ descriptor = classify_attachment(filename, content_type=content_type)
165
+
166
+ logger.info(
167
+ "documents.parse dispatching filename=%s kind=%s tenant=%s run_id=%s",
168
+ filename,
169
+ descriptor.kind.value,
170
+ ctx.tenant_id,
171
+ ctx.run_id,
172
+ )
173
+
174
+ extracted = False
175
+ content_out = ""
176
+
177
+ # PDF path — decode + delegate to the W2-CARVE-3 façade.
178
+ if descriptor.kind == AttachmentKind.PDF and pdf_b64 is not None:
179
+ pdf_bytes = _decode_pdf_bytes(pdf_b64)
180
+ content_out = extract_pdf_text(pdf_bytes) or ""
181
+ extracted = bool(content_out)
182
+
183
+ # Text passthrough path — caller already supplied the content.
184
+ elif descriptor.kind == AttachmentKind.TEXT and text_payload is not None:
185
+ content_out = text_payload
186
+ extracted = bool(content_out)
187
+
188
+ # Viewer-3D / binary / unknown / classification-only path — no
189
+ # extraction, return descriptor metadata so the caller can decide
190
+ # whether to render a 3D viewer pane or fall back to a download.
191
+
192
+ return {
193
+ "filename": descriptor.filename,
194
+ "kind": descriptor.kind.value,
195
+ "extension": descriptor.extension,
196
+ "use_3d_viewer": descriptor.use_3d_viewer,
197
+ "content": content_out,
198
+ "extracted": extracted,
199
+ }
200
+
201
+
202
+ __all__ = ["documents_parse"]
@@ -0,0 +1,32 @@
1
+ """FTR-603 Wave 5 Conv C — Knowledge Services V1 primitive skills.
2
+
3
+ The 5 active V1 primitives (D-LOCKED-23 / PRD §8.11.3) are exposed here
4
+ as kernel-routable skills under the ``knowledge.*`` namespace. The 2
5
+ deferred primitives (``explain`` / ``context_pack``) ship as YAML
6
+ ``draft`` capabilities only — they do NOT register a kernel skill in V1
7
+ because their dispatch returns ``KS_PRIMITIVE_DEFERRED_V1_1`` at the
8
+ envelope layer (engine-core), not at the kernel layer.
9
+
10
+ V1 thin-proxy contract (Decision #122 / Q-W5C-1): each skill validates
11
+ its primitive request via the matching ``plm_shared.knowledge.primitives_v1``
12
+ schema and produces a structured response dict carrying:
13
+
14
+ {
15
+ "payload": <primitive response payload, dict>,
16
+ "source_refs": [<KnowledgeSourceRef as dict>, ...],
17
+ "evidence_refs": [<KnowledgeSourceRef as dict>, ...],
18
+ "connector_access": [<ConnectorAccessEntry as dict>, ...],
19
+ "baseline_version": <str>,
20
+ }
21
+
22
+ The envelope wrapping (engine-core ``KnowledgeServiceEnvelope.wrap``)
23
+ consumes this shape and produces the wire-shape ``KnowledgeEnvelopeV1``
24
+ on the Core side at dispatch time. Keeping the envelope wrapping on the
25
+ Core side preserves the import-linter contract
26
+ ``plm_skill_kernel-must-not-depend-on-plm_engine_core``.
27
+
28
+ Module layout per Decision #124 / Q-W5C-3 — one module per primitive.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ __all__: list[str] = []