subactor-shell 0.2.2__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.
@@ -0,0 +1,416 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ import re
7
+ import unicodedata
8
+ from dataclasses import dataclass, field
9
+ from difflib import SequenceMatcher
10
+ from pathlib import Path
11
+ from typing import Any, Iterable
12
+
13
+ from .intent_ir import IntentIR, IntentValidationError
14
+
15
+
16
+ _WORD_RE = re.compile(r"[\w.-]+", re.UNICODE)
17
+ _TEMPLATE_FIELD_RE = re.compile(r"\{([A-Za-z][A-Za-z0-9_.-]{0,63})\}")
18
+
19
+
20
+ def normalize_text(value: str) -> str:
21
+ value = unicodedata.normalize("NFKC", value).casefold()
22
+ value = re.sub(r"[^\w{}:/.=-]+", " ", value, flags=re.UNICODE)
23
+ return " ".join(value.split())
24
+
25
+
26
+ def text_tokens(value: str) -> list[str]:
27
+ return [token.casefold() for token in _WORD_RE.findall(normalize_text(value)) if token]
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class IntentDefinition:
32
+ id: str
33
+ description: str
34
+ phrases: list[str]
35
+ required_args: list[str] = field(default_factory=list)
36
+ optional_args: list[str] = field(default_factory=list)
37
+ defaults: dict[str, Any] = field(default_factory=dict)
38
+ execution: dict[str, Any] = field(default_factory=lambda: {"kind": "chat"})
39
+ risk: str = "low"
40
+ constraints: list[str] = field(default_factory=list)
41
+ source: str = "builtin"
42
+
43
+ @property
44
+ def allowed_args(self) -> set[str]:
45
+ return set(self.required_args) | set(self.optional_args) | set(self.defaults)
46
+
47
+ def to_summary(self) -> dict[str, Any]:
48
+ return {
49
+ "id": self.id,
50
+ "description": self.description,
51
+ "required_args": self.required_args,
52
+ "optional_args": self.optional_args,
53
+ "risk": self.risk,
54
+ "execution_kind": str(self.execution.get("kind", "chat")),
55
+ "phrases": self.phrases[:6],
56
+ "source": self.source,
57
+ }
58
+
59
+ def validate_ir(self, payload: IntentIR | dict[str, Any]) -> IntentIR:
60
+ ir = payload if isinstance(payload, IntentIR) else IntentIR.from_dict(payload)
61
+ if ir.intent_id != self.id:
62
+ raise IntentValidationError("IntentIR nie odpowiada wybranej definicji")
63
+ unknown = set(ir.args) - self.allowed_args
64
+ if unknown:
65
+ raise IntentValidationError(
66
+ "IntentIR zawiera niedozwolone argumenty: " + ", ".join(sorted(unknown))
67
+ )
68
+ args = {**self.defaults, **ir.args}
69
+ missing = [name for name in self.required_args if name not in args or args[name] in ("", None)]
70
+ unresolved = list(dict.fromkeys([*ir.unresolved, *missing]))
71
+ constraints = list(dict.fromkeys([*self.constraints, *ir.constraints]))
72
+ return IntentIR(
73
+ v=1,
74
+ intent_id=ir.intent_id,
75
+ mode=ir.mode,
76
+ args=args,
77
+ requirements=ir.requirements,
78
+ constraints=constraints,
79
+ unresolved=unresolved,
80
+ )
81
+
82
+
83
+ @dataclass(slots=True)
84
+ class CandidateMatch:
85
+ intent: IntentDefinition
86
+ score: float
87
+ matched_phrase: str
88
+ extracted_args: dict[str, str] = field(default_factory=dict)
89
+ exact: bool = False
90
+
91
+ def to_dict(self) -> dict[str, Any]:
92
+ return {
93
+ "intent_id": self.intent.id,
94
+ "score": round(self.score, 6),
95
+ "matched_phrase": self.matched_phrase,
96
+ "extracted_args": self.extracted_args,
97
+ "exact": self.exact,
98
+ }
99
+
100
+
101
+ class IntentCatalog:
102
+ def __init__(self, intents: Iterable[IntentDefinition]):
103
+ self._by_id: dict[str, IntentDefinition] = {}
104
+ for intent in intents:
105
+ if intent.id:
106
+ self._by_id[intent.id] = intent
107
+ serialized = json.dumps(
108
+ [item.to_summary() | {"execution": item.execution} for item in self.list()],
109
+ ensure_ascii=False,
110
+ sort_keys=True,
111
+ separators=(",", ":"),
112
+ )
113
+ self.fingerprint = hashlib.sha256(serialized.encode("utf-8")).hexdigest()
114
+
115
+ def get(self, intent_id: str) -> IntentDefinition | None:
116
+ return self._by_id.get(intent_id)
117
+
118
+ def list(self) -> list[IntentDefinition]:
119
+ return [self._by_id[key] for key in sorted(self._by_id)]
120
+
121
+ @classmethod
122
+ def load(cls, paths: Iterable[Path] = ()) -> "IntentCatalog":
123
+ intents = {item.id: item for item in builtin_intents()}
124
+ for path in paths:
125
+ for definition in load_intent_definitions(path):
126
+ intents[definition.id] = definition
127
+ return cls(intents.values())
128
+
129
+
130
+ class CandidateRetriever:
131
+ def __init__(self, catalog: IntentCatalog):
132
+ self.catalog = catalog
133
+ self._docs: list[tuple[IntentDefinition, str, list[str]]] = []
134
+ document_frequency: dict[str, int] = {}
135
+ for intent in catalog.list():
136
+ phrases = intent.phrases or [intent.description, intent.id]
137
+ for phrase in phrases:
138
+ tokens = text_tokens(_TEMPLATE_FIELD_RE.sub(" value ", phrase))
139
+ self._docs.append((intent, phrase, tokens))
140
+ for token in set(tokens):
141
+ document_frequency[token] = document_frequency.get(token, 0) + 1
142
+ count = max(1, len(self._docs))
143
+ self._idf = {
144
+ token: math.log((count + 1) / (frequency + 0.5)) + 1.0
145
+ for token, frequency in document_frequency.items()
146
+ }
147
+
148
+ def retrieve(self, text: str, *, top_k: int = 5) -> list[CandidateMatch]:
149
+ normalized = normalize_text(text)
150
+ query_tokens = text_tokens(normalized)
151
+ query_set = set(query_tokens)
152
+ best: dict[str, CandidateMatch] = {}
153
+ for intent, phrase, phrase_tokens in self._docs:
154
+ extracted = match_phrase_template(text, phrase)
155
+ normalized_phrase = normalize_text(_TEMPLATE_FIELD_RE.sub(" value ", phrase))
156
+ literal_exact = normalize_text(text) == normalize_text(phrase)
157
+ exact = literal_exact or extracted is not None
158
+ if exact:
159
+ score = 1.0 if literal_exact else 0.985
160
+ else:
161
+ phrase_set = set(phrase_tokens)
162
+ shared = query_set & phrase_set
163
+ query_weight = sum(self._idf.get(token, 1.0) for token in query_set) or 1.0
164
+ overlap = sum(self._idf.get(token, 1.0) for token in shared) / query_weight
165
+ sequence = SequenceMatcher(None, normalized, normalized_phrase).ratio()
166
+ containment = 1.0 if normalized_phrase and normalized_phrase in normalized else 0.0
167
+ coverage = len(shared) / max(1, len(phrase_set))
168
+ score = 0.48 * overlap + 0.24 * sequence + 0.18 * coverage + 0.10 * containment
169
+ candidate = CandidateMatch(
170
+ intent=intent,
171
+ score=max(0.0, min(1.0, score)),
172
+ matched_phrase=phrase,
173
+ extracted_args=extracted or {},
174
+ exact=exact,
175
+ )
176
+ previous = best.get(intent.id)
177
+ if previous is None or candidate.score > previous.score:
178
+ best[intent.id] = candidate
179
+ return sorted(best.values(), key=lambda item: (-item.score, item.intent.id))[: max(1, top_k)]
180
+
181
+
182
+ def match_phrase_template(text: str, phrase: str) -> dict[str, str] | None:
183
+ fields = list(_TEMPLATE_FIELD_RE.finditer(phrase))
184
+ if not fields:
185
+ return {} if normalize_text(text) == normalize_text(phrase) else None
186
+ parts: list[str] = []
187
+ cursor = 0
188
+ for index, match in enumerate(fields):
189
+ literal = phrase[cursor : match.start()]
190
+ escaped = re.escape(literal).replace(r"\ ", r"\s+")
191
+ parts.append(escaped)
192
+ parts.append(fr"(?P<f{index}>.+?)")
193
+ cursor = match.end()
194
+ parts.append(re.escape(phrase[cursor:]).replace(r"\ ", r"\s+"))
195
+ matched = re.match(r"^\s*" + "".join(parts) + r"\s*$", text, flags=re.IGNORECASE | re.UNICODE)
196
+ if not matched:
197
+ return None
198
+ result: dict[str, str] = {}
199
+ for index, field_match in enumerate(fields):
200
+ value = matched.group(f"f{index}").strip().strip("\"'")
201
+ if value:
202
+ result[field_match.group(1)] = value
203
+ return result
204
+
205
+
206
+ def builtin_intents() -> list[IntentDefinition]:
207
+ return [
208
+ IntentDefinition(
209
+ id="bridge.help",
210
+ description="Pokaż możliwości i bezpieczne komendy Subactor Shell Bridge.",
211
+ phrases=["pomoc", "pokaż pomoc", "co potrafisz", "help"],
212
+ execution={"kind": "builtin", "operation": "bridge.help", "effect": "read"},
213
+ ),
214
+ IntentDefinition(
215
+ id="session.list",
216
+ description="Pokaż zapisane sesje rozmów.",
217
+ phrases=["pokaż sesje", "lista sesji", "ostatnie rozmowy", "wymień sesje"],
218
+ optional_args=["limit"],
219
+ defaults={"limit": 20},
220
+ execution={
221
+ "kind": "builtin",
222
+ "operation": "session.list",
223
+ "effect": "read",
224
+ "argument_map": {"limit": "$args.limit"},
225
+ },
226
+ ),
227
+ IntentDefinition(
228
+ id="data.list",
229
+ description="Pokaż nazwy jawnych danych i artefaktów bez rozwijania treści.",
230
+ phrases=["pokaż dane", "lista danych", "jakie dane są zapisane"],
231
+ execution={"kind": "builtin", "operation": "data.list", "effect": "read"},
232
+ ),
233
+ IntentDefinition(
234
+ id="secret.list",
235
+ description="Pokaż aliasy i referencje sekretów bez odczytywania wartości.",
236
+ phrases=["pokaż bindingi sekretów", "lista sekretów", "jakie sekrety są podpięte"],
237
+ execution={"kind": "builtin", "operation": "secret.list", "effect": "read"},
238
+ constraints=["no_secret_export"],
239
+ ),
240
+ IntentDefinition(
241
+ id="usage.summary",
242
+ description="Pokaż telemetryczne zużycie tokenów i udział tras lokalnych.",
243
+ phrases=["pokaż zużycie tokenów", "statystyki tokenów", "metryki llm", "koszt tokenów"],
244
+ execution={"kind": "builtin", "operation": "usage.summary", "effect": "read"},
245
+ ),
246
+ IntentDefinition(
247
+ id="control.status",
248
+ description="Sprawdź status istniejącego Subactor Control przez cli.status.",
249
+ phrases=[
250
+ "pokaż status subactora",
251
+ "sprawdź status subactora",
252
+ "status control",
253
+ "jakie zadania są otwarte",
254
+ "jakie zadania sa otwarte",
255
+ "pokaż otwarte zadania",
256
+ "pokaz otwarte zadania",
257
+ "co teraz",
258
+ ],
259
+ execution={
260
+ "kind": "connector",
261
+ "connector": "subactor_cli",
262
+ "operation": "cli.status",
263
+ "effect": "read",
264
+ },
265
+ ),
266
+ IntentDefinition(
267
+ id="control.plan",
268
+ description="Utwórz plan w istniejącym Subactor Control przez cli.plan.",
269
+ phrases=["zaplanuj {request}", "przygotuj plan {request}", "stwórz plan {request}"],
270
+ required_args=["request"],
271
+ execution={
272
+ "kind": "connector",
273
+ "connector": "subactor_control",
274
+ "operation": "cli.plan",
275
+ "effect": "read",
276
+ "argument_map": {"request": "$args.request"},
277
+ },
278
+ ),
279
+ ]
280
+
281
+
282
+ def load_intent_definitions(path: Path) -> list[IntentDefinition]:
283
+ path = path.expanduser()
284
+ if not path.exists():
285
+ return []
286
+ files = [path]
287
+ if path.is_dir():
288
+ files = sorted(item for item in path.rglob("*.json") if "schema" not in item.name.casefold())
289
+ result: list[IntentDefinition] = []
290
+ for file_path in files:
291
+ if not file_path.is_file():
292
+ continue
293
+ try:
294
+ payload = json.loads(file_path.read_text(encoding="utf-8"))
295
+ except (OSError, json.JSONDecodeError):
296
+ continue
297
+ if isinstance(payload, dict) and isinstance(payload.get("intents"), list):
298
+ entries: list[Any] = payload["intents"]
299
+ elif isinstance(payload, list):
300
+ entries = payload
301
+ else:
302
+ entries = [payload]
303
+ for entry in entries:
304
+ definition = _definition_from_payload(entry, source=str(file_path))
305
+ if definition:
306
+ result.append(definition)
307
+ return result
308
+
309
+
310
+ def _nested(payload: dict[str, Any], *paths: str) -> Any:
311
+ for path in paths:
312
+ current: Any = payload
313
+ found = True
314
+ for segment in path.split("."):
315
+ if not isinstance(current, dict) or segment not in current:
316
+ found = False
317
+ break
318
+ current = current[segment]
319
+ if found:
320
+ return current
321
+ return None
322
+
323
+
324
+ def _string_list(value: Any) -> list[str]:
325
+ if isinstance(value, str):
326
+ return [value]
327
+ if isinstance(value, list):
328
+ return [str(item) for item in value if isinstance(item, (str, int, float)) and str(item).strip()]
329
+ return []
330
+
331
+
332
+ def _definition_from_payload(payload: Any, *, source: str) -> IntentDefinition | None:
333
+ if not isinstance(payload, dict):
334
+ return None
335
+ intent_id = _nested(
336
+ payload,
337
+ "intent_id",
338
+ "intentId",
339
+ "id",
340
+ "name",
341
+ "nlp_uri",
342
+ "nlpUri",
343
+ "uri",
344
+ "nlp.uri",
345
+ )
346
+ if not isinstance(intent_id, str) or not intent_id.strip():
347
+ return None
348
+ intent_id = intent_id.strip()
349
+ description = str(_nested(payload, "description", "summary", "title", "nlp.description") or intent_id)
350
+ phrases: list[str] = []
351
+ for key in ("phrases", "createPhrases", "examples", "utterances", "nlp.phrases", "nlp.createPhrases"):
352
+ phrases.extend(_string_list(_nested(payload, key)))
353
+ phrases = list(dict.fromkeys(item.strip() for item in phrases if item.strip())) or [description, intent_id]
354
+
355
+ required = _string_list(
356
+ _nested(
357
+ payload,
358
+ "required_args",
359
+ "requiredArgs",
360
+ "required",
361
+ "situation_schema.required",
362
+ "situationSchema.required",
363
+ "input_schema.required",
364
+ "inputSchema.required",
365
+ )
366
+ )
367
+ properties = _nested(
368
+ payload,
369
+ "situation_schema.properties",
370
+ "situationSchema.properties",
371
+ "input_schema.properties",
372
+ "inputSchema.properties",
373
+ "args_schema.properties",
374
+ )
375
+ optional = [str(key) for key in properties if str(key) not in required] if isinstance(properties, dict) else []
376
+ optional.extend(_string_list(_nested(payload, "optional_args", "optionalArgs")))
377
+ optional = list(dict.fromkeys(optional))
378
+
379
+ execution = _nested(payload, "execution", "runtime.execution")
380
+ if not isinstance(execution, dict):
381
+ connector = _nested(payload, "connector", "connector_id", "connectorId")
382
+ operation = _nested(payload, "operation", "operation_id", "operationId")
383
+ recipe = _nested(payload, "recipe", "recipe_uri", "recipeUri", "urirun")
384
+ if connector and operation:
385
+ execution = {
386
+ "kind": "connector",
387
+ "connector": str(connector),
388
+ "operation": str(operation),
389
+ "effect": str(_nested(payload, "effect") or "external_write"),
390
+ }
391
+ elif recipe:
392
+ execution = {
393
+ "kind": "process_pack",
394
+ "connector": str(connector or "process_pack"),
395
+ "operation": str(recipe),
396
+ "effect": str(_nested(payload, "effect") or "external_write"),
397
+ }
398
+ else:
399
+ execution = {"kind": "chat"}
400
+ defaults = _nested(payload, "defaults", "nlp.defaults")
401
+ if not isinstance(defaults, dict):
402
+ defaults = {}
403
+ constraints = _string_list(_nested(payload, "constraints", "policy.constraints"))
404
+ risk = str(_nested(payload, "risk", "risk_class", "riskClass") or "low")
405
+ return IntentDefinition(
406
+ id=intent_id,
407
+ description=description,
408
+ phrases=phrases,
409
+ required_args=required,
410
+ optional_args=optional,
411
+ defaults=defaults,
412
+ execution=dict(execution),
413
+ risk=risk,
414
+ constraints=constraints,
415
+ source=source,
416
+ )