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,153 @@
1
+ """V1 in-memory deterministic seed corpus for the knowledge.* skills.
2
+
3
+ FTR-603 Wave 5 Conv C / Decision #122 — V1 thin-proxy harvest. The 5
4
+ active primitives operate on a deterministic in-process seed corpus
5
+ so the kernel container is self-contained at boot (no Azure / file-
6
+ system dependency). V1.1 replaces this seed with a real backend bind
7
+ through the lazy-import boundary documented on each skill module.
8
+
9
+ The seed shape is the minimum that keeps the 5 primitives' V1
10
+ contract demonstrable:
11
+
12
+ * Two corpora — ``demo`` (3 BPMN-flavoured records) + ``foundation``
13
+ (2 entity records) so cross-corpus + cross-record cases are
14
+ exercisable from tests.
15
+ * One entity catalog — keyed by ``(entity_kind, surface_form)`` for
16
+ ``resolve`` exact-match.
17
+ * One rule set — ``v1_smoke`` for ``validate`` keyed by required
18
+ fields.
19
+
20
+ Adding fixtures here is a deliberate, additive YAML-style edit; the
21
+ runtime contract on the request / response Pydantic schemas is the
22
+ frozen V1 contract — this seed is wallpaper.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from typing import Any, Dict, List, Tuple
27
+
28
+
29
+ # ── Records (search / get_record / retrieve) ──────────────────────────
30
+
31
+
32
+ CORPUS_RECORDS: Dict[str, Dict[str, Dict[str, Any]]] = {
33
+ "demo": {
34
+ "rec-001": {
35
+ "title": "Approval flow",
36
+ "body": "Route every change order through the design-review gate.",
37
+ "source_uri": "doc://demo/rec-001",
38
+ "revision": "rev-1",
39
+ "payload": {"tags": ["bpmn", "approval"]},
40
+ },
41
+ "rec-002": {
42
+ "title": "Manufacturing release",
43
+ "body": "Promote a part to released after BOM consolidation.",
44
+ "source_uri": "doc://demo/rec-002",
45
+ "revision": "rev-1",
46
+ "payload": {"tags": ["bpmn", "release"]},
47
+ },
48
+ "rec-003": {
49
+ "title": "ECO impact analysis",
50
+ "body": "Evaluate the impact of a change on dependent assemblies.",
51
+ "source_uri": "doc://demo/rec-003",
52
+ "revision": "rev-2",
53
+ "payload": {"tags": ["change", "impact"]},
54
+ },
55
+ },
56
+ "foundation": {
57
+ "ent-part-001": {
58
+ "title": "Part WASHER-M6",
59
+ "body": "Foundation entity for the WASHER-M6 standard hardware part.",
60
+ "source_uri": "foundation://parts/WASHER-M6",
61
+ "revision": "rev-1",
62
+ "payload": {"kind": "Part", "category": "hardware"},
63
+ },
64
+ "ent-part-002": {
65
+ "title": "Part BRACKET-A1",
66
+ "body": "Foundation entity for the BRACKET-A1 fabricated bracket.",
67
+ "source_uri": "foundation://parts/BRACKET-A1",
68
+ "revision": "rev-1",
69
+ "payload": {"kind": "Part", "category": "fabricated"},
70
+ },
71
+ },
72
+ }
73
+ """Two-corpus seed: ``demo`` (3 records) + ``foundation`` (2 records)."""
74
+
75
+
76
+ # ── Entity resolution catalog (resolve) ───────────────────────────────
77
+
78
+
79
+ ENTITY_CATALOG: Dict[Tuple[str, str], Dict[str, Any]] = {
80
+ ("part", "washer-m6"): {
81
+ "canonical_id": "ent-part-001",
82
+ "label": "WASHER-M6",
83
+ "confidence": 0.95,
84
+ },
85
+ ("part", "bracket-a1"): {
86
+ "canonical_id": "ent-part-002",
87
+ "label": "BRACKET-A1",
88
+ "confidence": 0.92,
89
+ },
90
+ ("part", "bracket"): {
91
+ "canonical_id": "ent-part-002",
92
+ "label": "BRACKET-A1",
93
+ "confidence": 0.55,
94
+ },
95
+ }
96
+ """V1 entity catalog keyed by ``(entity_kind, surface_form_lower)``."""
97
+
98
+
99
+ # ── Rule sets (validate) ──────────────────────────────────────────────
100
+
101
+
102
+ RULE_SETS: Dict[str, List[Dict[str, Any]]] = {
103
+ "v1_smoke": [
104
+ {
105
+ "rule_id": "v1.required.title",
106
+ "severity": "error",
107
+ "message": "Field 'title' is required.",
108
+ "kind": "required",
109
+ "field": "title",
110
+ },
111
+ {
112
+ "rule_id": "v1.required.body",
113
+ "severity": "warning",
114
+ "message": "Field 'body' is recommended.",
115
+ "kind": "required",
116
+ "field": "body",
117
+ },
118
+ {
119
+ "rule_id": "v1.evidence_refs.curation",
120
+ "severity": "error",
121
+ "message": (
122
+ "Each evidence_refs entry must be backed by a "
123
+ "connector_access_index row (curation index)."
124
+ ),
125
+ "kind": "curation",
126
+ "field": "evidence_refs",
127
+ },
128
+ ]
129
+ }
130
+ """V1 ``v1_smoke`` rule set with 3 rules covering required-fields +
131
+ the curation-index evidence-ref check (per readiness sweep §3.2)."""
132
+
133
+
134
+ def known_corpus(corpus_id: str) -> bool:
135
+ return corpus_id in CORPUS_RECORDS
136
+
137
+
138
+ def list_records(corpus_id: str) -> List[Tuple[str, Dict[str, Any]]]:
139
+ """Return ``[(record_id, record_payload), ...]`` for the corpus.
140
+
141
+ Empty list when the corpus is unknown — V1 contract is open-world
142
+ on corpus_id (no error; consumer treats empty as miss).
143
+ """
144
+ return list(CORPUS_RECORDS.get(corpus_id, {}).items())
145
+
146
+
147
+ __all__ = [
148
+ "CORPUS_RECORDS",
149
+ "ENTITY_CATALOG",
150
+ "RULE_SETS",
151
+ "known_corpus",
152
+ "list_records",
153
+ ]
@@ -0,0 +1,102 @@
1
+ """``knowledge.get_record`` / 1.0.0 / L1 — fetch a record by id.
2
+
3
+ FTR-603 Wave 5 Conv C / Decision #122. V1 thin-proxy harvest of the
4
+ D-LOCKED-23 active V1 ``get_record`` primitive (PRD §8.11.3).
5
+
6
+ V1 contract:
7
+
8
+ payload = {
9
+ "corpus_id": str, # required
10
+ "record_id": str, # required
11
+ }
12
+
13
+ Output:
14
+
15
+ {
16
+ "payload": {"record": <KnowledgeRecord as dict | None>},
17
+ "source_refs": [<KnowledgeSourceRef as dict>] | [],
18
+ "evidence_refs": [],
19
+ "connector_access": [],
20
+ "baseline_version": "v1-corpus@rev-1",
21
+ }
22
+
23
+ V1 implementation: dictionary lookup against the in-process seed corpus.
24
+ Missing record → ``payload.record = None`` (consumer maps to 404-style
25
+ outcome). V1.1 backs onto a real corpus connector through the same
26
+ public contract.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ from typing import Any, Dict
31
+
32
+ from pydantic import ValidationError
33
+
34
+ from plm_shared.knowledge.primitives_v1 import (
35
+ KnowledgeGetRecordRequest,
36
+ KnowledgeGetRecordResponse,
37
+ KnowledgeRecord,
38
+ )
39
+ from plm_shared.knowledge_envelope import KnowledgeSourceRef
40
+ from plm_skill_kernel import SkillContext, skill
41
+
42
+ from ._v1_corpus import CORPUS_RECORDS
43
+ from .search import V1_BASELINE_VERSION
44
+
45
+
46
+ @skill(
47
+ id="knowledge.get_record",
48
+ version="1.0.0",
49
+ description=(
50
+ "Fetch a Knowledge record by id from a corpus. L1 read-only "
51
+ "surface; V1 thin-proxy against the in-process seed corpus."
52
+ ),
53
+ min_autonomy="L1",
54
+ streaming=False,
55
+ hitl_required=False,
56
+ )
57
+ async def knowledge_get_record(
58
+ payload: Dict[str, Any], ctx: SkillContext
59
+ ) -> Dict[str, Any]:
60
+ """Validate the request + emit the record (or None) + envelope artefacts."""
61
+ try:
62
+ request = KnowledgeGetRecordRequest.model_validate(payload)
63
+ except ValidationError as exc:
64
+ raise ValueError(
65
+ f"knowledge.get_record: invalid request payload — {exc.errors()}"
66
+ ) from exc
67
+
68
+ raw = CORPUS_RECORDS.get(request.corpus_id, {}).get(request.record_id)
69
+
70
+ if raw is None:
71
+ response = KnowledgeGetRecordResponse(record=None)
72
+ source_refs: list[dict] = []
73
+ else:
74
+ record = KnowledgeRecord(
75
+ record_id=request.record_id,
76
+ source_uri=raw["source_uri"],
77
+ corpus_id=request.corpus_id,
78
+ revision=raw["revision"],
79
+ title=raw.get("title"),
80
+ body=raw.get("body"),
81
+ payload=dict(raw.get("payload", {})),
82
+ )
83
+ response = KnowledgeGetRecordResponse(record=record)
84
+ source_refs = [
85
+ KnowledgeSourceRef(
86
+ ref_id=request.record_id,
87
+ ref_type=f"corpus:{request.corpus_id}",
88
+ title=record.title,
89
+ locator=record.source_uri,
90
+ ).model_dump()
91
+ ]
92
+
93
+ return {
94
+ "payload": response.model_dump(),
95
+ "source_refs": source_refs,
96
+ "evidence_refs": [],
97
+ "connector_access": [],
98
+ "baseline_version": V1_BASELINE_VERSION,
99
+ }
100
+
101
+
102
+ __all__ = ["knowledge_get_record"]
@@ -0,0 +1,143 @@
1
+ """``knowledge.resolve`` / 1.0.0 / L1 — entity resolution.
2
+
3
+ FTR-603 Wave 5 Conv C / Decision #122. V1 thin-proxy harvest of the
4
+ D-LOCKED-23 active V1 ``resolve`` primitive (PRD §8.11.3).
5
+
6
+ V1 contract:
7
+
8
+ payload = {
9
+ "entity_kind": str, # required
10
+ "surface_form": str, # required
11
+ "corpus_id": str, # required
12
+ "context": dict, # default {}
13
+ }
14
+
15
+ Output:
16
+
17
+ {
18
+ "payload": {
19
+ "candidates": [<KnowledgeResolution as dict>, ...],
20
+ "best": <KnowledgeResolution as dict | None>,
21
+ },
22
+ "source_refs": [<KnowledgeSourceRef as dict>, ...],
23
+ "evidence_refs": [],
24
+ "connector_access": [],
25
+ "baseline_version": "v1-catalog@rev-1",
26
+ }
27
+
28
+ V1 implementation: exact + normalised-substring match against the
29
+ in-process entity catalog
30
+ (:data:`plm_skill_kernel.skills.knowledge._v1_corpus.ENTITY_CATALOG`).
31
+ The catalog is keyed by ``(entity_kind, surface_form_lower)``;
32
+ returns 0..N candidates ordered by descending confidence.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ from typing import Any, Dict, List
37
+
38
+ from pydantic import ValidationError
39
+
40
+ from plm_shared.knowledge.primitives_v1 import (
41
+ KnowledgeResolution,
42
+ KnowledgeResolveRequest,
43
+ KnowledgeResolveResponse,
44
+ )
45
+ from plm_shared.knowledge_envelope import KnowledgeSourceRef
46
+ from plm_skill_kernel import SkillContext, skill
47
+
48
+ from ._v1_corpus import ENTITY_CATALOG
49
+
50
+ V1_BASELINE_VERSION = "v1-catalog@rev-1"
51
+
52
+
53
+ def _candidates(entity_kind: str, surface_form: str) -> List[KnowledgeResolution]:
54
+ """V1 deterministic resolution.
55
+
56
+ Returns 0..N candidates ordered by descending confidence. Match
57
+ rules (V1):
58
+
59
+ * Exact lookup ``(entity_kind, surface_form_lower)`` — full confidence.
60
+ * Substring fallback — every entry whose surface_form is a
61
+ substring of the requested surface_form (or vice versa) is
62
+ emitted with a 0.4 confidence floor.
63
+
64
+ The catalog is small (≤4 entries V1) so the linear scan is fine;
65
+ V1.1 swaps in a real resolver behind the same skill contract.
66
+ """
67
+ needle = (entity_kind, surface_form.strip().lower())
68
+ out: List[KnowledgeResolution] = []
69
+ seen: set[str] = set()
70
+
71
+ direct = ENTITY_CATALOG.get(needle)
72
+ if direct is not None:
73
+ out.append(KnowledgeResolution(**direct))
74
+ seen.add(direct["canonical_id"])
75
+
76
+ needle_lower = needle[1]
77
+ for (kind, form), candidate in ENTITY_CATALOG.items():
78
+ if kind != entity_kind:
79
+ continue
80
+ if candidate["canonical_id"] in seen:
81
+ continue
82
+ if form in needle_lower or needle_lower in form:
83
+ out.append(
84
+ KnowledgeResolution(
85
+ canonical_id=candidate["canonical_id"],
86
+ label=candidate.get("label"),
87
+ confidence=min(0.4, candidate["confidence"]),
88
+ )
89
+ )
90
+ seen.add(candidate["canonical_id"])
91
+
92
+ out.sort(key=lambda r: r.confidence, reverse=True)
93
+ return out
94
+
95
+
96
+ @skill(
97
+ id="knowledge.resolve",
98
+ version="1.0.0",
99
+ description=(
100
+ "Entity resolution to a canonical id (Foundation). L1 "
101
+ "read-only surface; V1 thin-proxy against the in-process "
102
+ "entity catalog."
103
+ ),
104
+ min_autonomy="L1",
105
+ streaming=False,
106
+ hitl_required=False,
107
+ )
108
+ async def knowledge_resolve(
109
+ payload: Dict[str, Any], ctx: SkillContext
110
+ ) -> Dict[str, Any]:
111
+ """Validate the request + emit candidates + envelope artefacts."""
112
+ try:
113
+ request = KnowledgeResolveRequest.model_validate(payload)
114
+ except ValidationError as exc:
115
+ raise ValueError(
116
+ f"knowledge.resolve: invalid request payload — {exc.errors()}"
117
+ ) from exc
118
+
119
+ candidates = _candidates(request.entity_kind, request.surface_form)
120
+ best = candidates[0] if candidates else None
121
+
122
+ response = KnowledgeResolveResponse(candidates=candidates, best=best)
123
+
124
+ source_refs = [
125
+ KnowledgeSourceRef(
126
+ ref_id=c.canonical_id,
127
+ ref_type=f"entity:{request.entity_kind}",
128
+ title=c.label,
129
+ locator=f"foundation://entities/{c.canonical_id}",
130
+ ).model_dump()
131
+ for c in candidates
132
+ ]
133
+
134
+ return {
135
+ "payload": response.model_dump(),
136
+ "source_refs": source_refs,
137
+ "evidence_refs": [],
138
+ "connector_access": [],
139
+ "baseline_version": V1_BASELINE_VERSION,
140
+ }
141
+
142
+
143
+ __all__ = ["V1_BASELINE_VERSION", "knowledge_resolve"]
@@ -0,0 +1,143 @@
1
+ """``knowledge.retrieve`` / 1.0.0 / L1 — top-K ranked retrieval.
2
+
3
+ FTR-603 Wave 5 Conv C / Decision #122. V1 thin-proxy harvest of the
4
+ D-LOCKED-23 active V1 ``retrieve`` primitive (PRD §8.11.3).
5
+
6
+ V1 contract:
7
+
8
+ payload = {
9
+ "corpus_id": str, # required
10
+ "query": str, # required
11
+ "baseline_version": str, # required (obligation 2 binding)
12
+ "top_k": int, # default 10, 1..100
13
+ "filters": dict, # default {}
14
+ }
15
+
16
+ Output:
17
+
18
+ {
19
+ "payload": {"hits": [<KnowledgeHit as dict>, ...]},
20
+ "source_refs": [<KnowledgeSourceRef as dict>, ...],
21
+ "evidence_refs": [<KnowledgeSourceRef as dict>, ...],
22
+ "connector_access": [],
23
+ "baseline_version": <echo of request.baseline_version>,
24
+ }
25
+
26
+ The retrieve primitive's distinguishing feature vs ``search`` is the
27
+ mandatory ``baseline_version`` binding (PRD §8.11.3 obligation 2).
28
+ The skill echoes the caller-supplied baseline back into the response
29
+ artefacts; the engine-core envelope stamps it onto the wire envelope.
30
+
31
+ V1 implementation: substring + position score (same scorer as
32
+ ``search``) over a baseline-filtered slice. Records whose ``revision``
33
+ differs from the requested ``baseline_version`` (when the version is
34
+ prefixed ``rev-``) are excluded; ``v1-corpus@*`` baselines accept
35
+ every record.
36
+
37
+ The retrieve primitive emits ``evidence_refs`` (obligation 4) — every
38
+ hit also surfaces as an evidence pointer so consumers can audit the
39
+ provenance chain without a follow-up call.
40
+ """
41
+ from __future__ import annotations
42
+
43
+ from typing import Any, Dict, List
44
+
45
+ from pydantic import ValidationError
46
+
47
+ from plm_shared.knowledge.primitives_v1 import (
48
+ KnowledgeHit,
49
+ KnowledgeRecord,
50
+ KnowledgeRetrieveRequest,
51
+ KnowledgeRetrieveResponse,
52
+ )
53
+ from plm_shared.knowledge_envelope import KnowledgeSourceRef
54
+ from plm_skill_kernel import SkillContext, skill
55
+
56
+ from ._v1_corpus import CORPUS_RECORDS
57
+ from .search import _score_substring
58
+
59
+
60
+ def _accepts_baseline(record_revision: str, baseline_version: str) -> bool:
61
+ """V1 baseline filter — accept all when baseline starts with ``v1-``."""
62
+ if baseline_version.startswith("v1-"):
63
+ return True
64
+ if baseline_version.startswith("rev-"):
65
+ return record_revision == baseline_version
66
+ return True
67
+
68
+
69
+ @skill(
70
+ id="knowledge.retrieve",
71
+ version="1.0.0",
72
+ description=(
73
+ "Top-K ranked retrieval over a baseline-versioned slice. L1 "
74
+ "read-only surface; V1 thin-proxy with mandatory "
75
+ "baseline_version binding (PRD §8.11.3 obligation 2)."
76
+ ),
77
+ min_autonomy="L1",
78
+ streaming=False,
79
+ hitl_required=False,
80
+ )
81
+ async def knowledge_retrieve(
82
+ payload: Dict[str, Any], ctx: SkillContext
83
+ ) -> Dict[str, Any]:
84
+ """Validate the request + emit ranked hits + envelope artefacts."""
85
+ try:
86
+ request = KnowledgeRetrieveRequest.model_validate(payload)
87
+ except ValidationError as exc:
88
+ raise ValueError(
89
+ f"knowledge.retrieve: invalid request payload — {exc.errors()}"
90
+ ) from exc
91
+
92
+ records = CORPUS_RECORDS.get(request.corpus_id, {})
93
+ scored: List[KnowledgeHit] = []
94
+ for rec_id, raw in records.items():
95
+ if not _accepts_baseline(raw["revision"], request.baseline_version):
96
+ continue
97
+ text_blob = " ".join(
98
+ v
99
+ for v in (raw.get("title"), raw.get("body"))
100
+ if isinstance(v, str)
101
+ )
102
+ score = _score_substring(text_blob, request.query)
103
+ if score <= 0.0:
104
+ continue
105
+ scored.append(
106
+ KnowledgeHit(
107
+ score=score,
108
+ record=KnowledgeRecord(
109
+ record_id=rec_id,
110
+ source_uri=raw["source_uri"],
111
+ corpus_id=request.corpus_id,
112
+ revision=raw["revision"],
113
+ title=raw.get("title"),
114
+ body=raw.get("body"),
115
+ payload=dict(raw.get("payload", {})),
116
+ ),
117
+ )
118
+ )
119
+ scored.sort(key=lambda h: h.score, reverse=True)
120
+ hits = scored[: request.top_k]
121
+
122
+ response = KnowledgeRetrieveResponse(hits=hits)
123
+
124
+ refs = [
125
+ KnowledgeSourceRef(
126
+ ref_id=h.record.record_id,
127
+ ref_type=f"corpus:{h.record.corpus_id}",
128
+ title=h.record.title,
129
+ locator=h.record.source_uri,
130
+ ).model_dump()
131
+ for h in hits
132
+ ]
133
+
134
+ return {
135
+ "payload": response.model_dump(),
136
+ "source_refs": refs,
137
+ "evidence_refs": refs,
138
+ "connector_access": [],
139
+ "baseline_version": request.baseline_version,
140
+ }
141
+
142
+
143
+ __all__ = ["knowledge_retrieve"]
@@ -0,0 +1,161 @@
1
+ """``knowledge.search`` / 1.0.0 / L1 — semantic / hybrid search.
2
+
3
+ FTR-603 Wave 5 Conv C / Decision #122. V1 thin-proxy harvest of the
4
+ D-LOCKED-23 active V1 ``search`` primitive (PRD §8.11.3).
5
+
6
+ V1 contract surface (from
7
+ :class:`plm_shared.knowledge.primitives_v1.KnowledgeSearchRequest`):
8
+
9
+ payload = {
10
+ "corpus_id": str, # required
11
+ "query": str, # required
12
+ "top_k": int, # default 10, 1..100
13
+ "filters": dict, # default {}
14
+ }
15
+
16
+ Output:
17
+
18
+ {
19
+ "payload": {
20
+ "hits": [<KnowledgeHit as dict>, ...],
21
+ "total": int,
22
+ },
23
+ "source_refs": [<KnowledgeSourceRef as dict>, ...],
24
+ "evidence_refs": [<KnowledgeSourceRef as dict>, ...],
25
+ "connector_access": [], # V1 corpus is local — no connector reach-out
26
+ "baseline_version": "v1-corpus@rev-1",
27
+ }
28
+
29
+ V1 implementation: deterministic substring-match against the in-process
30
+ seed corpus (:mod:`plm_skill_kernel.skills.knowledge._v1_corpus`).
31
+ V1.1 will swap to the production retrieval backend through the same
32
+ public skill contract — the lazy backend-binding pattern in the
33
+ ``agents.suggest`` precedent (Wave 3 Conv A) is the V1.1 template.
34
+
35
+ Read-only by design — no connector_access entries emitted (Foundation /
36
+ local corpus reads are not connector calls). Obligation 8 / invariant
37
+ #11 trivially satisfied.
38
+ """
39
+ from __future__ import annotations
40
+
41
+ from typing import Any, Dict, List
42
+
43
+ from pydantic import ValidationError
44
+
45
+ from plm_shared.knowledge.primitives_v1 import (
46
+ KnowledgeHit,
47
+ KnowledgeRecord,
48
+ KnowledgeSearchRequest,
49
+ KnowledgeSearchResponse,
50
+ )
51
+ from plm_shared.knowledge_envelope import KnowledgeSourceRef
52
+ from plm_skill_kernel import SkillContext, skill
53
+
54
+ from ._v1_corpus import CORPUS_RECORDS
55
+
56
+ V1_BASELINE_VERSION = "v1-corpus@rev-1"
57
+
58
+
59
+ def _score_substring(haystack: str, needle: str) -> float:
60
+ """V1 deterministic relevance score in [0, 1].
61
+
62
+ Returns 0.0 when the needle is not found; otherwise weights inverse
63
+ position + length match. The score is corpus-relative, NOT a
64
+ normalised semantic score — consumers MUST treat it as opaque.
65
+ """
66
+ if not needle:
67
+ return 0.0
68
+ h = haystack.lower()
69
+ n = needle.lower()
70
+ idx = h.find(n)
71
+ if idx < 0:
72
+ return 0.0
73
+ return round(1.0 - (idx / (len(h) + 1)), 4)
74
+
75
+
76
+ def _record_dict(corpus_id: str, record_id: str, raw: Dict[str, Any]) -> Dict[str, Any]:
77
+ return KnowledgeRecord(
78
+ record_id=record_id,
79
+ source_uri=raw["source_uri"],
80
+ corpus_id=corpus_id,
81
+ revision=raw["revision"],
82
+ title=raw.get("title"),
83
+ body=raw.get("body"),
84
+ payload=dict(raw.get("payload", {})),
85
+ ).model_dump()
86
+
87
+
88
+ @skill(
89
+ id="knowledge.search",
90
+ version="1.0.0",
91
+ description=(
92
+ "Semantic / hybrid search over a Knowledge corpus. L1 read-only "
93
+ "surface; V1 thin-proxy against the in-process seed corpus."
94
+ ),
95
+ min_autonomy="L1",
96
+ streaming=False,
97
+ hitl_required=False,
98
+ )
99
+ async def knowledge_search(
100
+ payload: Dict[str, Any], ctx: SkillContext
101
+ ) -> Dict[str, Any]:
102
+ """Validate the request + emit V1 hits + envelope artefacts."""
103
+ try:
104
+ request = KnowledgeSearchRequest.model_validate(payload)
105
+ except ValidationError as exc:
106
+ raise ValueError(
107
+ f"knowledge.search: invalid request payload — {exc.errors()}"
108
+ ) from exc
109
+
110
+ records = CORPUS_RECORDS.get(request.corpus_id, {})
111
+ scored: List[KnowledgeHit] = []
112
+ for rec_id, raw in records.items():
113
+ # V1 substring match against title + body; filters are ignored
114
+ # at V1 (kept on the request for V1.1 contract preservation).
115
+ text_blob = " ".join(
116
+ v
117
+ for v in (raw.get("title"), raw.get("body"))
118
+ if isinstance(v, str)
119
+ )
120
+ score = _score_substring(text_blob, request.query)
121
+ if score <= 0.0:
122
+ continue
123
+ scored.append(
124
+ KnowledgeHit(
125
+ score=score,
126
+ record=KnowledgeRecord(
127
+ record_id=rec_id,
128
+ source_uri=raw["source_uri"],
129
+ corpus_id=request.corpus_id,
130
+ revision=raw["revision"],
131
+ title=raw.get("title"),
132
+ body=raw.get("body"),
133
+ payload=dict(raw.get("payload", {})),
134
+ ),
135
+ )
136
+ )
137
+ scored.sort(key=lambda h: h.score, reverse=True)
138
+ hits = scored[: request.top_k]
139
+
140
+ response = KnowledgeSearchResponse(hits=hits, total=len(scored))
141
+
142
+ source_refs = [
143
+ KnowledgeSourceRef(
144
+ ref_id=h.record.record_id,
145
+ ref_type=f"corpus:{h.record.corpus_id}",
146
+ title=h.record.title,
147
+ locator=h.record.source_uri,
148
+ ).model_dump()
149
+ for h in hits
150
+ ]
151
+
152
+ return {
153
+ "payload": response.model_dump(),
154
+ "source_refs": source_refs,
155
+ "evidence_refs": [],
156
+ "connector_access": [],
157
+ "baseline_version": V1_BASELINE_VERSION,
158
+ }
159
+
160
+
161
+ __all__ = ["V1_BASELINE_VERSION", "knowledge_search"]