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,334 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Callable
8
+
9
+ from .catalog import CandidateMatch, CandidateRetriever, IntentCatalog, normalize_text
10
+ from .config import AppConfig
11
+ from .intent_ir import IntentIR, IntentValidationError, intent_ir_schema
12
+ from .models import Session
13
+ from .providers import ProviderBundle
14
+ from .providers.base import ProviderError
15
+ from .secret_refs import SecretResolver
16
+ from .store import Store
17
+ from .token_budget import TokenUsage
18
+
19
+
20
+ ProviderBuilder = Callable[[Any, SecretResolver], ProviderBundle]
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class ParserUsageRecord:
25
+ provider: str
26
+ model: str
27
+ purpose: str
28
+ usage: TokenUsage
29
+ latency_ms: int = 0
30
+
31
+
32
+ @dataclass(slots=True)
33
+ class RoutingDecision:
34
+ route: str
35
+ reason: str
36
+ confidence: float = 0.0
37
+ intent: IntentIR | None = None
38
+ provider: str = ""
39
+ model: str = ""
40
+ candidates: list[CandidateMatch] = field(default_factory=list)
41
+ parser_usage: list[ParserUsageRecord] = field(default_factory=list)
42
+ parser_errors: list[str] = field(default_factory=list)
43
+ cache_hit: bool = False
44
+
45
+ @property
46
+ def intent_id(self) -> str:
47
+ return self.intent.intent_id if self.intent else ""
48
+
49
+ def route_context(self) -> dict[str, Any]:
50
+ return {
51
+ "route": self.route,
52
+ "reason": self.reason,
53
+ "confidence": round(self.confidence, 4),
54
+ "intent": self.intent.to_dict() if self.intent else None,
55
+ "candidates": [
56
+ {
57
+ "intent_id": item.intent.id,
58
+ "score": round(item.score, 4),
59
+ "required_args": item.intent.required_args,
60
+ "description": item.intent.description[:160],
61
+ }
62
+ for item in self.candidates[:5]
63
+ ],
64
+ "parser_errors": self.parser_errors[-3:],
65
+ }
66
+
67
+
68
+ class Router:
69
+ def __init__(
70
+ self,
71
+ config: AppConfig,
72
+ store: Store,
73
+ resolver: SecretResolver,
74
+ provider_builder: ProviderBuilder,
75
+ catalog: IntentCatalog,
76
+ ):
77
+ self.config = config
78
+ self.store = store
79
+ self.resolver = resolver
80
+ self.provider_builder = provider_builder
81
+ self.catalog = catalog
82
+ self.retriever = CandidateRetriever(catalog)
83
+ options = config.orchestration
84
+ self.enabled = bool(options.get("enabled", True))
85
+ self.mode = str(options.get("mode", "active")).strip().lower()
86
+ self.top_k = max(1, int(options.get("top_k", 5)))
87
+ self.min_candidate_score = float(options.get("min_candidate_score", 0.32))
88
+ self.deterministic_threshold = float(options.get("deterministic_threshold", 0.93))
89
+ self.local_threshold = float(options.get("local_execute_threshold", 0.82))
90
+ self.cheap_threshold = float(options.get("cheap_remote_threshold", 0.68))
91
+ self.max_output_tokens = max(64, int(options.get("max_parser_output_tokens", 192)))
92
+ self.cache_ttl = max(1, int(options.get("cache_ttl_seconds", 86_400)))
93
+ self.local_provider = str(options.get("local_parser_provider", "")).strip()
94
+ self.local_model = str(options.get("local_parser_model", "")).strip()
95
+ self.cheap_provider = str(options.get("cheap_parser_provider", "")).strip()
96
+ self.cheap_model = str(options.get("cheap_parser_model", "")).strip()
97
+ self.large_provider = str(options.get("large_provider", "")).strip()
98
+ self.large_model = str(options.get("large_model", "")).strip()
99
+
100
+ async def route(self, session: Session, text: str, *, cancel_event=None) -> RoutingDecision:
101
+ if not self.enabled or self.mode == "off":
102
+ return RoutingDecision(
103
+ route="chat_provider",
104
+ reason="Orkiestracja DSL jest wyłączona",
105
+ provider=session.provider,
106
+ model=session.model,
107
+ )
108
+
109
+ candidates = self.retriever.retrieve(text, top_k=self.top_k)
110
+ top_score = candidates[0].score if candidates else 0.0
111
+ margin = top_score - (candidates[1].score if len(candidates) > 1 else 0.0)
112
+ cache_key = self._cache_key(text)
113
+ cached = self.store.cache_get(cache_key)
114
+ if cached:
115
+ definition = self.catalog.get(str(cached.get("intent_id", "")))
116
+ if definition:
117
+ try:
118
+ intent = definition.validate_ir(cached)
119
+ return RoutingDecision(
120
+ route="cache",
121
+ reason="Powtórzone polecenie odtworzono z cache walidowanego IntentIR",
122
+ confidence=float(cached.get("_confidence", 0.95)),
123
+ intent=intent,
124
+ candidates=candidates,
125
+ cache_hit=True,
126
+ )
127
+ except IntentValidationError:
128
+ pass
129
+
130
+ deterministic = self._deterministic(candidates)
131
+ if deterministic:
132
+ intent, confidence = deterministic
133
+ self._cache(cache_key, intent, confidence)
134
+ return RoutingDecision(
135
+ route="deterministic",
136
+ reason="Exact/template phrase match oraz lokalna walidacja argumentów",
137
+ confidence=confidence,
138
+ intent=intent,
139
+ candidates=candidates,
140
+ )
141
+
142
+ if not candidates or top_score < self.min_candidate_score:
143
+ return RoutingDecision(
144
+ route="chat_provider",
145
+ reason="Brak wystarczająco bliskiego intentu w lokalnym katalogu",
146
+ confidence=top_score,
147
+ provider=self.large_provider or session.provider,
148
+ model=self.large_model or session.model,
149
+ candidates=candidates,
150
+ )
151
+
152
+ attempts: list[ParserUsageRecord] = []
153
+ errors: list[str] = []
154
+ configured: list[tuple[str, str, str, float]] = []
155
+ seen: set[tuple[str, str]] = set()
156
+ for route, provider_name, model_name, threshold in (
157
+ ("local_4b", self.local_provider, self.local_model, self.local_threshold),
158
+ ("cheap_remote", self.cheap_provider, self.cheap_model, self.cheap_threshold),
159
+ ("large_remote", self.large_provider, self.large_model, 0.50),
160
+ ):
161
+ if not provider_name:
162
+ continue
163
+ profile = self.config.provider(provider_name)
164
+ model = model_name or profile.model
165
+ key = (provider_name, model)
166
+ if key in seen:
167
+ continue
168
+ seen.add(key)
169
+ configured.append((route, provider_name, model, threshold))
170
+
171
+ for route, provider_name, model_name, threshold in configured:
172
+ intent, usage_record, error, confidence = await self._parse_with_provider(
173
+ text,
174
+ candidates,
175
+ route=route,
176
+ provider_name=provider_name,
177
+ model_name=model_name,
178
+ top_score=top_score,
179
+ margin=margin,
180
+ )
181
+ if usage_record:
182
+ attempts.append(usage_record)
183
+ if error:
184
+ errors.append(error)
185
+ if intent is not None and confidence >= threshold:
186
+ self._cache(cache_key, intent, confidence)
187
+ return RoutingDecision(
188
+ route=route,
189
+ reason="Poprawny IntentIR z krótkiej listy kandydatów i walidacji lokalnej",
190
+ confidence=self._calibrated(intent.intent_id, route, confidence),
191
+ intent=intent,
192
+ provider=provider_name,
193
+ model=model_name,
194
+ candidates=candidates,
195
+ parser_usage=attempts,
196
+ parser_errors=errors,
197
+ )
198
+
199
+ return RoutingDecision(
200
+ route="chat_provider",
201
+ reason="Parsery DSL nie zwróciły pewnego IntentIR; fallback do rozmowy z ograniczonym kontekstem",
202
+ confidence=top_score,
203
+ provider=self.large_provider or session.provider,
204
+ model=self.large_model or session.model,
205
+ candidates=candidates,
206
+ parser_usage=attempts,
207
+ parser_errors=errors,
208
+ )
209
+
210
+ def _deterministic(self, candidates: list[CandidateMatch]) -> tuple[IntentIR, float] | None:
211
+ if not candidates:
212
+ return None
213
+ candidate = candidates[0]
214
+ if not candidate.exact and candidate.score < self.deterministic_threshold:
215
+ return None
216
+ definition = candidate.intent
217
+ effect = str(definition.execution.get("effect", "read"))
218
+ mode = "execute" if effect == "read" else "plan"
219
+ ir = IntentIR.from_dict(
220
+ {
221
+ "v": 1,
222
+ "intent_id": definition.id,
223
+ "mode": mode,
224
+ "args": {**definition.defaults, **candidate.extracted_args},
225
+ "requirements": [],
226
+ "constraints": definition.constraints,
227
+ "unresolved": [],
228
+ }
229
+ )
230
+ ir = definition.validate_ir(ir)
231
+ confidence = 0.99 if candidate.exact else candidate.score
232
+ if ir.unresolved:
233
+ confidence = min(confidence, 0.69)
234
+ return ir, confidence
235
+
236
+ async def _parse_with_provider(
237
+ self,
238
+ text: str,
239
+ candidates: list[CandidateMatch],
240
+ *,
241
+ route: str,
242
+ provider_name: str,
243
+ model_name: str,
244
+ top_score: float,
245
+ margin: float,
246
+ ) -> tuple[IntentIR | None, ParserUsageRecord | None, str, float]:
247
+ started = time.perf_counter()
248
+ try:
249
+ profile = self.config.provider(provider_name)
250
+ bundle = self.provider_builder(profile, self.resolver)
251
+ except (KeyError, ValueError, ProviderError) as exc:
252
+ return None, None, f"{route}: nie można uruchomić providera ({exc})", 0.0
253
+
254
+ shortlist = [
255
+ {
256
+ "intent_id": item.intent.id,
257
+ "description": item.intent.description,
258
+ "required_args": item.intent.required_args,
259
+ "optional_args": item.intent.optional_args,
260
+ "defaults": item.intent.defaults,
261
+ "risk": item.intent.risk,
262
+ }
263
+ for item in candidates
264
+ ]
265
+ prompt = {
266
+ "user_text": text,
267
+ "candidate_intents": shortlist,
268
+ "rules": [
269
+ "Wybierz wyłącznie intent_id z candidate_intents.",
270
+ "Nie twórz command, shell, connector, endpoint ani sekretów.",
271
+ "args może zawierać wyłącznie pola zadeklarowane przez wybrany intent.",
272
+ "Brakujące wymagane pola wpisz do unresolved.",
273
+ "Dla odczytu użyj execute; dla zmiany stanu użyj plan.",
274
+ ],
275
+ }
276
+ messages = [
277
+ {
278
+ "role": "user",
279
+ "content": json.dumps(prompt, ensure_ascii=False, sort_keys=True, separators=(",", ":")),
280
+ }
281
+ ]
282
+ try:
283
+ completion = await bundle.provider.complete_structured(
284
+ messages,
285
+ model=model_name,
286
+ json_schema=intent_ir_schema([item.intent.id for item in candidates]),
287
+ schema_name="subactor_intent_ir_v1",
288
+ max_output_tokens=self.max_output_tokens,
289
+ reasoning_effort=profile.reasoning_effort or None,
290
+ )
291
+ definition = self.catalog.get(str(completion.data.get("intent_id", "")))
292
+ allowed_ids = {item.intent.id for item in candidates}
293
+ if not definition or definition.id not in allowed_ids:
294
+ raise IntentValidationError("Model wybrał intent spoza shortlisty")
295
+ intent = definition.validate_ir(completion.data)
296
+ confidence = min(0.97, 0.57 + 0.28 * top_score + 0.15 * max(0.0, margin))
297
+ if intent.unresolved:
298
+ confidence = min(confidence, 0.69)
299
+ record = ParserUsageRecord(
300
+ provider=provider_name,
301
+ model=model_name,
302
+ purpose=f"intent_parser:{route}",
303
+ usage=completion.usage,
304
+ latency_ms=int((time.perf_counter() - started) * 1000),
305
+ )
306
+ return intent, record, "", confidence
307
+ except (ProviderError, IntentValidationError, ValueError, KeyError) as exc:
308
+ return (
309
+ None,
310
+ ParserUsageRecord(
311
+ provider=provider_name,
312
+ model=model_name,
313
+ purpose=f"intent_parser:{route}",
314
+ usage=TokenUsage(),
315
+ latency_ms=int((time.perf_counter() - started) * 1000),
316
+ ),
317
+ f"{route}: {exc}",
318
+ 0.0,
319
+ )
320
+
321
+ def _calibrated(self, intent_id: str, route: str, base: float) -> float:
322
+ historical = self.store.historical_success(intent_id, route)
323
+ if historical is None:
324
+ return base
325
+ return max(0.0, min(1.0, 0.75 * base + 0.25 * historical))
326
+
327
+ def _cache_key(self, text: str) -> str:
328
+ payload = normalize_text(text) + "\0" + self.catalog.fingerprint
329
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
330
+
331
+ def _cache(self, key: str, intent: IntentIR, confidence: float) -> None:
332
+ payload = intent.to_dict()
333
+ payload["_confidence"] = confidence
334
+ self.store.cache_set(key, payload, self.cache_ttl)
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from pathlib import Path
6
+ from urllib.parse import unquote
7
+
8
+ from .vault import VaultClient
9
+
10
+
11
+ class SecretRefError(RuntimeError):
12
+ pass
13
+
14
+
15
+ _ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
16
+
17
+
18
+ class SecretResolver:
19
+ def __init__(self, vault_config: dict, *, vault_transport=None):
20
+ self.vault_config = vault_config
21
+ self._vault_transport = vault_transport
22
+ self._vault_client: VaultClient | None = None
23
+
24
+ @staticmethod
25
+ def _read_env(reference: str) -> str:
26
+ name = reference.removeprefix("env://")
27
+ if not _ENV_NAME.fullmatch(name):
28
+ raise SecretRefError("Nieprawidłowa referencja env://")
29
+ value = os.environ.get(name)
30
+ if value is None:
31
+ raise SecretRefError(f"Brak zmiennej środowiskowej {name}")
32
+ return value
33
+
34
+ @staticmethod
35
+ def _file_path(reference: str) -> Path:
36
+ remainder = unquote(reference.removeprefix("file://"))
37
+ if not remainder or "?" in remainder or "#" in remainder:
38
+ raise SecretRefError("Nieprawidłowa referencja file://")
39
+ return Path(remainder).expanduser()
40
+
41
+ @classmethod
42
+ def _read_file(cls, reference: str) -> str:
43
+ path = cls._file_path(reference)
44
+ try:
45
+ stat = path.stat()
46
+ if not path.is_file():
47
+ raise SecretRefError(f"Referencja nie wskazuje pliku: {path}")
48
+ if stat.st_size > 1024 * 1024:
49
+ raise SecretRefError("Plik sekretu jest większy niż 1 MiB")
50
+ return path.read_text(encoding="utf-8").rstrip("\r\n")
51
+ except OSError as exc:
52
+ raise SecretRefError(f"Nie można odczytać pliku sekretu: {path}") from exc
53
+
54
+ def resolve_without_vault(self, reference: str) -> str:
55
+ if reference.startswith("env://"):
56
+ return self._read_env(reference)
57
+ if reference.startswith("file://"):
58
+ return self._read_file(reference)
59
+ if reference.startswith("vault://"):
60
+ raise SecretRefError("Token dostępu do Vault nie może sam pochodzić z Vault")
61
+ raise SecretRefError("Obsługiwane referencje to env://, file:// i vault://")
62
+
63
+ @property
64
+ def vault(self) -> VaultClient:
65
+ if self._vault_client is None:
66
+ token_ref = str(self.vault_config.get("token_ref", "env://VAULT_TOKEN"))
67
+ self._vault_client = VaultClient(
68
+ address=str(self.vault_config.get("address", "http://127.0.0.1:8200")),
69
+ token_loader=lambda: self.resolve_without_vault(token_ref),
70
+ namespace=str(self.vault_config.get("namespace", "")),
71
+ verify_tls=bool(self.vault_config.get("verify_tls", True)),
72
+ timeout_seconds=float(self.vault_config.get("timeout_seconds", 10.0)),
73
+ transport=self._vault_transport,
74
+ )
75
+ return self._vault_client
76
+
77
+ def resolve(self, reference: str) -> str:
78
+ if reference.startswith("env://") or reference.startswith("file://"):
79
+ return self.resolve_without_vault(reference)
80
+ if reference.startswith("vault://"):
81
+ return self.vault.read_field(reference)
82
+ raise SecretRefError("Obsługiwane referencje to env://, file:// i vault://")