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,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] = []
|