uriloop 0.3.1__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.
uriloop/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """uriloop — wspólna płaszczyzna URI/URN dla sterowania przeglądarką (CDP) i pulpitem (noVNC) z pętlą zwrotną CQRS+ES."""
2
+ __version__ = "0.3.1"
uriloop/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
uriloop/ai/__init__.py ADDED
File without changes
uriloop/ai/decision.py ADDED
@@ -0,0 +1,305 @@
1
+ """System One — jednoprzebiegowe decyzje typowane nad katalogiem procesów (NUL-019/020/022).
2
+
3
+ Pytania (stały kontrakt wellmanifest.system-one-decision/v1):
4
+ uri_choice choice — które Action URI / proc:// realizuje polecenie w bieżącym stanie (2..255 kandydatów)
5
+ risk_score score — ryzyko 1..3 (odczyt / zmiana odwracalna / nieodwracalne lub zewnętrzne)
6
+ execution_allowed noul — czy wolno wykonać bez potwierdzenia (negacja „nie uruchamiaj” => false)
7
+ + needs_plan noul — czy zadanie wymaga wieloetapowego procesu (routing do generatora)
8
+
9
+ Backendy (kolejność z routera): Liquid d1 przez Ollama `/v1/systemone` (natywnie, 0 tokenów wyjścia,
10
+ opcjonalnie ze zrzutem ekranu), model czatu przez Ollama `/api/chat` z JSON, lokalny skorer zero-gen.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import math
16
+ import os
17
+ import re
18
+ import time
19
+ from dataclasses import dataclass
20
+
21
+ from .. import nl
22
+ from ..uri import ActionUri, UriError
23
+ from . import router
24
+ from .http import post_json
25
+
26
+ SCHEMA = "wellmanifest.system-one-decision/v1"
27
+ MAX_CANDIDATES = 255
28
+ RISK_LEVELS = ["tylko odczyt, brak zmian stanu",
29
+ "zmiana stanu ekranu lub zadań, odwracalna",
30
+ "nieodwracalne, zewnętrzne, finansowe lub dane wrażliwe"]
31
+ AUTONOMY_MAX_RISK = {"suggest": 0, "confirm": 1, "auto": 2, "full": 3}
32
+ _NEG = re.compile(r"\b(nie\s+(uruchamiaj|wykonuj|klikaj|rob|wysylaj|zatwierdzaj|usuwaj)|bez\s+wykonania|"
33
+ r"tylko\s+(sprawdz|pokaz|zaplanuj)|don'?t|do\s+not|dry[- ]?run|just\s+plan)\b")
34
+ _MULTI = re.compile(r"\b(a\s+nastepnie|potem|nastepnie|i\s+potem|kolejno|po\s+czym|then|and\s+then|"
35
+ r"wszystkie|kazdy|kazda|caly\s+proces|krok\s+po\s+kroku|zaloguj.*i)\b")
36
+
37
+
38
+ @dataclass
39
+ class Candidate:
40
+ uri: str
41
+ label: str
42
+ source: str # element | registry | composite | dockuri
43
+ effect: str = "write"
44
+ urn: str | None = None
45
+
46
+ @property
47
+ def prior_risk(self) -> int:
48
+ return {"read": 1, "write": 2, "external": 3}.get(self.effect, 2)
49
+
50
+
51
+ # ---------------- katalog kandydatów ----------------
52
+ def build_candidates(query: str, rt, *, extra: list[Candidate] | None = None) -> list[Candidate]:
53
+ """Kandydaci: akcje elementów z ostatniej obserwacji + rejestr (z parametrami z NL) + kompozycje + DockURI."""
54
+ cands: dict[str, Candidate] = {}
55
+ rest = query
56
+ for el in list(getattr(rt, "elements", {}).values())[:400]:
57
+ for a in el.get("actions", [])[:2]:
58
+ if "text=&" in a or a.endswith("text="):
59
+ if not (q := nl._QUOTED.search(query)):
60
+ continue
61
+ a = str(rt.registry.validate(a.replace("text=", "text=" + _q(q.group(1))))[0])
62
+ name = el.get("name") or el.get("selector") or el.get("tag")
63
+ verb = a.split("://", 1)[1].split("?", 1)[0]
64
+ cands.setdefault(a, Candidate(a, f"{verb} „{name}” ({el.get('kind')})", "element",
65
+ "write", el.get("urn")))
66
+ for p in rt.registry.processes.values():
67
+ if p.uri.startswith(("decide://", "plan://", "loop://")):
68
+ continue
69
+ scheme, tail = p.uri.split("://", 1)
70
+ dom, act = tail.split("/", 1)
71
+ params = nl._extract(p.params.get("properties", {}), rest)
72
+ if not nl._QUOTED.search(rest): # wolny tekst polecenia nie jest parametrem — tylko cytat „…”
73
+ params.pop("text", None)
74
+ params.pop("key", None)
75
+ if p.uri in ("browser://page/click", "browser://page/type") and not (
76
+ {"urn", "selector", "text"} & set(params) or {"x", "y"} <= set(params)):
77
+ continue # akcja bez celu nie jest wykonalnym kandydatem
78
+ try:
79
+ a = str(rt.registry.validate(ActionUri(scheme, dom, act, params))[0])
80
+ except UriError:
81
+ continue
82
+ cands.setdefault(a, Candidate(a, p.title, "registry", p.effect))
83
+ for comp in getattr(rt, "composites", None).list() if getattr(rt, "composites", None) else []:
84
+ u = f"proc://uriloop/run?name={comp['name']}"
85
+ cands.setdefault(u, Candidate(u, f"proces: {comp.get('title', comp['name'])}", "composite",
86
+ comp.get("effect", "write"), comp.get("urn")))
87
+ for c in extra or []:
88
+ cands.setdefault(c.uri, c)
89
+ ranked = sorted(cands.values(), key=lambda c: -affinity(query, c))
90
+ out = ranked[:MAX_CANDIDATES]
91
+ if len(out) < 2:
92
+ out.append(Candidate("log://events/tail?n=5", "pokaż ostatnie zdarzenia (brak lepszej akcji)", "registry", "read"))
93
+ return out
94
+
95
+
96
+ def _q(text: str) -> str:
97
+ from urllib.parse import quote
98
+ return quote(text, safe="")
99
+
100
+
101
+ # ---------------- skorer zero-gen (NUL-022 §2) ----------------
102
+ def _tokens(s: str) -> set[str]:
103
+ return {w for w in re.split(r"[^a-z0-9]+", nl.normalize(s)) if len(w) > 2}
104
+
105
+
106
+ def affinity(query: str, c: Candidate) -> float:
107
+ q = _tokens(query)
108
+ lab = _tokens(c.label) | _tokens(c.uri.replace("://", " ").replace("/", " ").replace("?", " "))
109
+ if not q:
110
+ return 0.0
111
+ overlap = len(q & lab) / len(q)
112
+ fuzzy = sum(1 for w in q for v in lab if w not in lab and len(w) > 4 and nl.levenshtein(w, v, 2) <= 2) / len(q)
113
+ nq = nl.normalize(query)
114
+ name = re.search(r"„([^”]+)”", c.label)
115
+ exact = 1.0 if name and nl.normalize(name.group(1)) and nl.normalize(name.group(1)) in nq else 0.0
116
+ alias = 0.0
117
+ try:
118
+ hit = nl.dispatch(query, _REG_CACHE.get())
119
+ if hit and c.uri.startswith(hit.key):
120
+ alias = 0.6
121
+ except UriError:
122
+ pass
123
+ src = {"element": 0.15, "composite": 0.1, "dockuri": 0.05}.get(c.source, 0.0)
124
+ return overlap + 0.5 * fuzzy + 1.2 * exact + alias + src * (overlap > 0 or exact > 0)
125
+
126
+
127
+ class _RegCache:
128
+ reg = None
129
+
130
+ def get(self):
131
+ if self.reg is None:
132
+ from ..registry import Registry
133
+ self.reg = Registry.load()
134
+ return self.reg
135
+
136
+
137
+ _REG_CACHE = _RegCache()
138
+
139
+
140
+ def _softmax(xs: list[float], temp: float = 0.25) -> list[float]:
141
+ m = max(xs)
142
+ e = [math.exp((x - m) / temp) for x in xs]
143
+ s = sum(e)
144
+ return [v / s for v in e]
145
+
146
+
147
+ def zero_gen(query: str, cands: list[Candidate]) -> dict:
148
+ probs = _softmax([affinity(query, c) for c in cands])
149
+ best = max(range(len(cands)), key=lambda i: probs[i])
150
+ nq = nl.normalize(query)
151
+ neg = bool(_NEG.search(nq))
152
+ prior = cands[best].prior_risk
153
+ risk = {str(i): (0.8 if i + 1 == prior else 0.1) for i in range(3)}
154
+ multi = bool(_MULTI.search(nq)) or nq.count(",") >= 2
155
+ return {"choice": {c.uri: round(p, 6) for c, p in zip(cands, probs)},
156
+ "risk": risk, "allowed": 0.05 if neg else 0.9, "needs_plan": 0.85 if multi else 0.15,
157
+ "usage": {"input_tokens": 0, "output_tokens": 0}}
158
+
159
+
160
+ # ---------------- Liquid d1 przez Ollama /v1/systemone ----------------
161
+ def _questions(cands: list[Candidate]) -> dict:
162
+ return {
163
+ "uri_choice": {"type": "choice",
164
+ "instructions": "Która akcja (Action URI) najlepiej realizuje polecenie użytkownika w obecnym stanie ekranu?",
165
+ "criteria": {c.uri: c.label[:500] for c in cands}},
166
+ "risk_score": {"type": "score",
167
+ "instructions": "Jakie jest ryzyko operacyjne wykonania tej akcji?",
168
+ "criteria": RISK_LEVELS},
169
+ "execution_allowed": {"type": "noul",
170
+ "instructions": "Czy polecenie pozwala wykonać akcję teraz, bez potwierdzenia "
171
+ "(brak negacji typu 'nie uruchamiaj', 'tylko zaplanuj')?"},
172
+ "needs_plan": {"type": "noul",
173
+ "instructions": "Czy polecenie wymaga wielu kolejnych kroków, których nie zrealizuje jedna akcja?"},
174
+ }
175
+
176
+
177
+ def d1_native(profile: dict, query: str, cands: list[Candidate], state: dict, images: list[bytes] | None,
178
+ timeout: float = 120) -> dict:
179
+ body = {"model": profile["model"], "state": {**state, "query": query},
180
+ "questions": _questions(cands), "keep_alive": int(os.environ.get("URILOOP_KEEP_ALIVE", "90"))}
181
+ if images:
182
+ body["images"] = [base64.b64encode(i).decode() for i in images]
183
+ data = post_json(router.base_url(profile) + "/v1/systemone", body, timeout=timeout)
184
+ a = data.get("answers") or {}
185
+ ch = a.get("uri_choice") or {}
186
+ probs = ch.get("probabilities") or {}
187
+ if set(probs) != {c.uri for c in cands} or ch.get("choice") not in probs:
188
+ raise ValueError("d1: choice poza katalogiem kandydatów")
189
+ risk = (a.get("risk_score") or {}).get("probabilities") or {}
190
+ if set(risk) != {"0", "1", "2"}:
191
+ raise ValueError("d1: niepoprawny rozkład score")
192
+ for k in ("execution_allowed", "needs_plan"):
193
+ v = (a.get(k) or {}).get("noul")
194
+ if not isinstance(v, (int, float)) or not 0 <= v <= 1:
195
+ raise ValueError(f"d1: niepoprawne noul {k}")
196
+ if (data.get("usage") or {}).get("output_tokens", 0) != 0:
197
+ raise ValueError("d1: model generował tekst (output_tokens != 0)")
198
+ return {"choice": probs, "risk": risk, "allowed": a["execution_allowed"]["noul"],
199
+ "needs_plan": a["needs_plan"]["noul"], "usage": data.get("usage")}
200
+
201
+
202
+ # ---------------- model czatu (np. qwen3) przez Ollama /api/chat ----------------
203
+ def ollama_json(profile: dict, query: str, cands: list[Candidate], state: dict, images=None, timeout: float = 120) -> dict:
204
+ ids = {f"a{i}": c for i, c in enumerate(cands)}
205
+ schema = {"type": "object", "required": ["choice", "risk", "allowed", "needs_plan"],
206
+ "properties": {"choice": {"enum": list(ids)}, "risk": {"enum": [1, 2, 3]},
207
+ "allowed": {"type": "boolean"}, "needs_plan": {"type": "boolean"}}}
208
+ catalog = "\n".join(f"{k}: {c.label} -> {c.uri}" for k, c in ids.items())
209
+ msg = (f"Polecenie: {query}\nStan: {state.get('summary', '')}\nKandydaci:\n{catalog}\n"
210
+ "Wybierz identyfikator akcji, ryzyko 1-3, czy wolno wykonać bez potwierdzenia "
211
+ "i czy potrzeba procesu wieloetapowego. Odpowiedz JSON.")
212
+ data = post_json(router.base_url(profile) + "/api/chat",
213
+ {"model": profile["model"], "messages": [{"role": "user", "content": msg}],
214
+ "format": schema, "stream": False, "think": False, "options": {"temperature": 0}},
215
+ timeout=timeout)
216
+ import json as _j
217
+ out = _j.loads(data["message"]["content"])
218
+ chosen = ids[out["choice"]].uri
219
+ n = len(cands)
220
+ hi = 0.84 if n > 1 else 1.0
221
+ probs = {c.uri: (hi if c.uri == chosen else (1 - hi) / (n - 1)) for c in cands}
222
+ risk = {str(i): (0.84 if i + 1 == int(out["risk"]) else 0.08) for i in range(3)}
223
+ return {"choice": probs, "risk": risk, "allowed": 0.9 if out["allowed"] else 0.1,
224
+ "needs_plan": 0.9 if out["needs_plan"] else 0.1,
225
+ "usage": {"input_tokens": data.get("prompt_eval_count", 0), "output_tokens": data.get("eval_count", 0)}}
226
+
227
+
228
+ BACKENDS = {"ollama-systemone": d1_native, "ollama-json": ollama_json,
229
+ "zero-gen": lambda p, q, c, s, i=None, timeout=0: zero_gen(q, c)}
230
+ FAMILY = {"ollama-systemone": ("liquid-d1", "system-one", "ollama-systemone"),
231
+ "ollama-json": ("semif", "token-score", "ollama-json"),
232
+ "zero-gen": ("jevlike", "candidate-scorer", "local-zero-gen")}
233
+
234
+
235
+ # ---------------- polityka (NUL-020) i koperta ----------------
236
+ def apply_policy(raw: dict, cands: list[Candidate], autonomy: str, min_conf: float) -> dict:
237
+ by_uri = {c.uri: c for c in cands}
238
+ probs = dict(raw["choice"])
239
+ total = sum(probs.values()) or 1.0
240
+ probs = {k: v / total for k, v in probs.items()}
241
+ selected = max(probs, key=probs.get)
242
+ rdist = {str(i + 1): float(raw["risk"][str(i)]) for i in range(3)}
243
+ rs = sum(rdist.values()) or 1.0
244
+ rdist = {k: round(v / rs, 6) for k, v in rdist.items()}
245
+ risk = max(int(max(rdist, key=rdist.get)), by_uri[selected].prior_risk if by_uri[selected].effect == "external" else 1)
246
+ allowed_p = float(raw["allowed"])
247
+ allowed, constraints = allowed_p >= 0.5, []
248
+ if not allowed:
249
+ constraints.append("POLICY-NOUL-NEGATION-BLOCKS-EXECUTION")
250
+ limit = AUTONOMY_MAX_RISK.get(autonomy, 2)
251
+ if risk > limit:
252
+ allowed = False
253
+ constraints.append(f"POLICY-AUTONOMY-{autonomy.upper()}-GATE")
254
+ conf = probs[selected]
255
+ if conf < min_conf:
256
+ constraints.append("POLICY-LOW-CONFIDENCE-ROUTES-TO-PLANNER")
257
+ return {"selected": selected, "probs": probs, "risk": risk, "rdist": rdist, "allowed": allowed,
258
+ "allowed_p": allowed_p, "constraints": constraints, "confidence": conf,
259
+ "blocked": not allowed and bool(set(constraints) - {"POLICY-LOW-CONFIDENCE-ROUTES-TO-PLANNER"})}
260
+
261
+
262
+ def decide(query: str, rt, *, autonomy: str | None = None, images: list[bytes] | None = None,
263
+ state: dict | None = None, extra: list[Candidate] | None = None, profiles: list[dict] | None = None) -> dict:
264
+ autonomy = autonomy or os.environ.get("URILOOP_AUTONOMY", "auto")
265
+ min_conf = float(os.environ.get("URILOOP_DECISION_MIN_CONF", "0.35"))
266
+ cands = build_candidates(query, rt, extra=extra)
267
+ state = dict(state or {})
268
+ errors = []
269
+ for profile in profiles or router.select("decision"):
270
+ t0 = time.monotonic()
271
+ try:
272
+ raw = BACKENDS[profile["backend"]](profile, query, cands, state,
273
+ images if "image" in profile.get("modalities", []) else None)
274
+ except Exception as exc: # noqa: BLE001 — przejście do kolejnego profilu
275
+ errors.append(f"{profile['id']}: {type(exc).__name__}: {exc}"[:300])
276
+ continue
277
+ pol = apply_policy(raw, cands, autonomy, min_conf)
278
+ fam, wire, transport = FAMILY[profile["backend"]]
279
+ envelope = {
280
+ "schema": SCHEMA, "model_profile_id": profile["id"], "family": fam, "wire_format": wire,
281
+ "state": {"query": query, "autonomy_mode": autonomy, "modality": "multimodal" if images else "text"},
282
+ "questions": [
283
+ {"id": "uri_choice", "type": "choice", "question": _questions(cands)["uri_choice"]["instructions"],
284
+ "options": [c.uri for c in cands]},
285
+ {"id": "risk_score", "type": "score", "question": "Ryzyko operacyjne 1..3", "options": ["1", "2", "3"]},
286
+ {"id": "execution_allowed", "type": "noul", "question": "Wykonanie bez potwierdzenia?",
287
+ "options": ["true", "false"]},
288
+ ],
289
+ "answers": {"uri_choice": pol["selected"], "risk_score": pol["risk"], "execution_allowed": pol["allowed"]},
290
+ "probabilities": {"uri_choice": {k: round(v, 6) for k, v in pol["probs"].items()},
291
+ "risk_score": pol["rdist"],
292
+ "execution_allowed": {"true": round(pol["allowed_p"], 6),
293
+ "false": round(1 - pol["allowed_p"], 6)}},
294
+ "selected_uri": pol["selected"], "confidence": round(pol["confidence"], 6),
295
+ "risk_score": pol["risk"], "execution_allowed": pol["allowed"],
296
+ "blocked_by_constraint": pol["blocked"], "applied_constraints": pol["constraints"],
297
+ "latency_ms": round((time.monotonic() - t0) * 1000, 2), "transport": transport,
298
+ }
299
+ sel = next(c for c in cands if c.uri == pol["selected"])
300
+ return {"envelope": envelope, "needs_plan": float(raw["needs_plan"]) >= 0.5 or
301
+ "POLICY-LOW-CONFIDENCE-ROUTES-TO-PLANNER" in pol["constraints"],
302
+ "selected": {"uri": sel.uri, "label": sel.label, "source": sel.source, "urn": sel.urn},
303
+ "top": sorted(({"uri": k, "p": round(v, 4)} for k, v in pol["probs"].items()), key=lambda x: -x["p"])[:5],
304
+ "usage": raw.get("usage"), "fallback_errors": errors, "candidates": len(cands)}
305
+ raise RuntimeError("brak dostępnego modelu decyzyjnego: " + "; ".join(errors))
@@ -0,0 +1,153 @@
1
+ """Generator procesów URI: cel w NL ─► proces złożony `proc://uriloop/<nazwa>` z katalogu DockURI.
2
+
3
+ Dostawcy (router, registry/models.json → role generator):
4
+ ollama /api/chat z `format` = JSON Schema (lokalnie, np. qwen3:8b na GPU 8 GB, qwen3.8:27b ≥ 20 GB)
5
+ deepseek OpenAI-compatible /chat/completions, response_format json_object (deepseek-v4-flash / -pro)
6
+ subllm proxy subactor/subllm (OpenAI-compatible) — failover i rozliczanie po stronie subllm
7
+
8
+ Zamknięta pętla jakości: odpowiedź → walidacja każdego kroku względem rejestru (fail-closed) →
9
+ lista błędów wraca do modelu jako poprawka (max URILOOP_GENERATOR_REPAIRS razy) → zapis z pochodzeniem.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import re
17
+ import time
18
+
19
+ from ..composites import CompositeStore, slug
20
+ from ..uri import UriError
21
+ from . import router
22
+ from .http import post_json
23
+
24
+ PLAN_SCHEMA = {
25
+ "type": "object", "required": ["name", "title", "steps"], "additionalProperties": False,
26
+ "properties": {
27
+ "name": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{1,62}$"},
28
+ "title": {"type": "string"},
29
+ "vars": {"type": "object", "additionalProperties": {
30
+ "type": "object", "properties": {"type": {"type": "string"}, "default": {}, "example": {},
31
+ "description": {"type": "string"}}}},
32
+ "steps": {"type": "array", "minItems": 1, "maxItems": 40, "items": {
33
+ "type": "object", "required": ["uri"], "properties": {"uri": {"type": "string"}, "why": {"type": "string"}}}},
34
+ "expects": {"type": "array", "items": {"type": "string"}},
35
+ },
36
+ }
37
+
38
+ SYSTEM = """Jesteś kompilatorem procesów URI (wellmanifest/nl-uri-dsl-llm, POA).
39
+ Zamieniasz cel użytkownika na proces złożony: listę kroków, z których KAŻDY jest kanonicznym Action URI
40
+ z katalogu poniżej (scheme://domain/action?param=wartość, wartości zakodowane procentowo).
41
+ Zasady:
42
+ - Używaj wyłącznie URI z katalogu i wyłącznie zadeklarowanych parametrów. Nie wymyślaj akcji.
43
+ - Gdy znasz URN elementu z bieżącej obserwacji, używaj go (browser://page/click?urn=…); w przeciwnym razie text= lub selector=.
44
+ - Po nawigacji lub zmianie strony dodaj browser://page/analyze, zanim użyjesz URN nowych elementów.
45
+ - Wartości zależne od użytkownika wyrażaj jako ${zmienna} i deklaruj w "vars" (type, example).
46
+ - "expects": warunki weryfikacji końca procesu: text~, url~, title~, element~, request~, changed~.
47
+ - Odpowiadasz WYŁĄCZNIE obiektem JSON: {"name","title","vars","steps":[{"uri","why"}],"expects"}."""
48
+
49
+
50
+ def _catalog(rt, limit_elements: int = 80) -> str:
51
+ lines = ["# Procesy (Action URI → parametry)"]
52
+ for p in rt.registry.processes.values():
53
+ if p.uri.startswith(("plan://", "loop://", "decide://")):
54
+ continue
55
+ props = p.params.get("properties", {})
56
+ req = set(p.params.get("required", []))
57
+ args = ", ".join(f"{k}{'*' if k in req else ''}:{v.get('type') or '|'.join(map(str, v.get('enum', [])))}"
58
+ for k, v in props.items())
59
+ lines.append(f"- {p.uri} ({p.effect}) — {p.title}" + (f" [{args}]" if args else ""))
60
+ comps = rt.composites.list()
61
+ if comps:
62
+ lines.append("# Istniejące procesy złożone (można wywołać jako krok)")
63
+ lines += [f"- {c['uri']} — {c.get('title')}" for c in comps[:50]]
64
+ if rt.elements:
65
+ lines.append("# Elementy bieżącej obserwacji (URN → akcje)")
66
+ for e in list(rt.elements.values())[:limit_elements]:
67
+ if e.get("actions"):
68
+ lines.append(f"- {e['kind']} „{e.get('name', '')[:60]}”: {e['actions'][0]}")
69
+ return "\n".join(lines)
70
+
71
+
72
+ def _extract_json(text: str) -> dict:
73
+ text = re.sub(r"<think>.*?</think>", "", text or "", flags=re.S).strip()
74
+ text = re.sub(r"^```(?:json)?|```$", "", text, flags=re.M).strip()
75
+ try:
76
+ return json.loads(text)
77
+ except json.JSONDecodeError:
78
+ a, b = text.find("{"), text.rfind("}")
79
+ if a != -1 and b > a:
80
+ return json.loads(text[a:b + 1])
81
+ raise
82
+
83
+
84
+ def _call(profile: dict, messages: list[dict], timeout: float) -> tuple[dict, dict]:
85
+ if profile["backend"] == "ollama-chat":
86
+ data = post_json(router.base_url(profile) + "/api/chat",
87
+ {"model": profile["model"], "messages": messages, "format": PLAN_SCHEMA, "stream": False,
88
+ "think": bool(profile.get("think", False)), "options": {"temperature": 0.1, "num_ctx": 16384}},
89
+ timeout=timeout)
90
+ usage = {"input_tokens": data.get("prompt_eval_count", 0), "output_tokens": data.get("eval_count", 0)}
91
+ return _extract_json(data["message"]["content"]), usage
92
+ if profile["backend"] == "openai-chat":
93
+ headers = {}
94
+ key = os.environ.get(profile.get("api_key_env", ""), "")
95
+ if key:
96
+ headers["Authorization"] = f"Bearer {key}"
97
+ headers["X-Subactor-Function"] = "process-generate"
98
+ headers["HTTP-Referer"] = os.environ.get("URILOOP_APP_URL", "https://github.com/wellmanifest/nl-uri-dsl-llm")
99
+ headers["X-OpenRouter-Title"] = os.environ.get("URILOOP_APP_NAME", "uriloop")
100
+ body = {"model": profile["model"], "messages": messages, "temperature": 0.1,
101
+ **({"response_format": profile["response_format"]} if profile.get("response_format") else {}),
102
+ **(profile.get("extra_body") or {})}
103
+ data = post_json(router.base_url(profile) + "/chat/completions", body, headers, timeout=timeout)
104
+ u = data.get("usage") or {}
105
+ return _extract_json(data["choices"][0]["message"]["content"]), {
106
+ "input_tokens": u.get("prompt_tokens", 0), "output_tokens": u.get("completion_tokens", 0)}
107
+ raise ValueError(f"unsupported generator backend {profile['backend']}")
108
+
109
+
110
+ def generate(goal: str, rt, *, name: str | None = None, profiles: list[dict] | None = None,
111
+ observation: str | None = None, save: bool = True, timeout: float = 180) -> dict:
112
+ store: CompositeStore = rt.composites
113
+ repairs = int(os.environ.get("URILOOP_GENERATOR_REPAIRS", "2"))
114
+ profiles = profiles or router.select("generator")
115
+ if not profiles:
116
+ raise RuntimeError("brak generatora: uruchom Ollama z modelem qwen3 lub ustaw DEEPSEEK_API_KEY")
117
+ prompt_hash = hashlib.sha256(goal.encode()).hexdigest()[:16]
118
+ errors_by_profile = []
119
+ for profile in profiles:
120
+ messages = [{"role": "system", "content": SYSTEM + "\n\n" + _catalog(rt)},
121
+ {"role": "user", "content": f"Cel: {goal}\n"
122
+ f"Bieżąca obserwacja: {observation or 'brak'}\n"
123
+ f"Nazwa procesu (slug): {name or slug(goal)}"}]
124
+ usage_total = {"input_tokens": 0, "output_tokens": 0}
125
+ attempts = []
126
+ t0 = time.monotonic()
127
+ try:
128
+ for attempt in range(repairs + 1):
129
+ doc, usage = _call(profile, messages, timeout)
130
+ for k in usage_total:
131
+ usage_total[k] += usage.get(k, 0) or 0
132
+ doc = doc if isinstance(doc, dict) else {}
133
+ doc["name"] = name or doc.get("name") or slug(goal)
134
+ if not re.match(r"^[a-z0-9][a-z0-9-]{1,62}$", doc["name"]):
135
+ doc["name"] = slug(doc["name"])
136
+ errs = store.validate(doc)
137
+ attempts.append({"attempt": attempt, "errors": errs[:10]})
138
+ if not errs:
139
+ doc["goal"] = goal
140
+ doc["generated_by"] = {"profile": profile["id"], "provider": profile.get("provider"),
141
+ "model": profile["model"], "prompt_sha256_16": prompt_hash,
142
+ "repairs": attempt, "usage": usage_total,
143
+ "ms": int((time.monotonic() - t0) * 1000)}
144
+ saved = store.save(doc) if save else doc
145
+ return {"process": saved, "attempts": attempts, "profile": profile["id"],
146
+ "fallback_errors": errors_by_profile}
147
+ messages += [{"role": "assistant", "content": json.dumps(doc, ensure_ascii=False)},
148
+ {"role": "user", "content": "Proces jest niepoprawny względem katalogu. Popraw wyłącznie te błędy "
149
+ "i zwróć cały JSON:\n- " + "\n- ".join(errs[:10])}]
150
+ errors_by_profile.append(f"{profile['id']}: walidacja nieudana po {repairs} poprawkach: {attempts[-1]['errors'][:3]}")
151
+ except Exception as exc: # noqa: BLE001 — failover do kolejnego profilu
152
+ errors_by_profile.append(f"{profile['id']}: {type(exc).__name__}: {exc}"[:300])
153
+ raise UriError("generator nie wytworzył poprawnego procesu: " + " | ".join(errors_by_profile))
uriloop/ai/http.py ADDED
@@ -0,0 +1,35 @@
1
+ """Minimalny klient HTTP/JSON (stdlib) — wspólny dla Ollama, DeepSeek i proxy subllm (REUSE-013)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import urllib.error
7
+ import urllib.request
8
+
9
+ APP_NAME = os.environ.get("URILOOP_APP_NAME", "uriloop")
10
+
11
+
12
+ class LlmHttpError(RuntimeError):
13
+ def __init__(self, status: int, body: str, url: str):
14
+ super().__init__(f"HTTP {status} from {url}: {body[:300]}")
15
+ self.status, self.body = status, body
16
+
17
+
18
+ def _req(url: str, data: bytes | None, headers: dict | None, timeout: float, method: str):
19
+ h = {"Content-Type": "application/json", "User-Agent": f"{APP_NAME}/0.2",
20
+ "X-Subactor-Application": APP_NAME, **(headers or {})}
21
+ req = urllib.request.Request(url, data=data, headers=h, method=method)
22
+ try:
23
+ with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (adresy z konfiguracji operatora)
24
+ raw = r.read()
25
+ except urllib.error.HTTPError as exc:
26
+ raise LlmHttpError(exc.code, exc.read().decode("utf-8", "replace"), url) from None
27
+ return json.loads(raw or b"{}")
28
+
29
+
30
+ def get_json(url: str, headers: dict | None = None, timeout: float = 10) -> dict:
31
+ return _req(url, None, headers, timeout, "GET")
32
+
33
+
34
+ def post_json(url: str, body: dict, headers: dict | None = None, timeout: float = 120) -> dict:
35
+ return _req(url, json.dumps(body, ensure_ascii=False).encode(), headers, timeout, "POST")
uriloop/ai/router.py ADDED
@@ -0,0 +1,129 @@
1
+ """Router profili modeli (NUL-022, wellmanifest/nohardcode).
2
+
3
+ Profil wybierany jest z registry/models.json na podstawie:
4
+ 1. zmiennych środowiskowych (URILOOP_DECISION_MODEL, DOCKURI_DECISION_MODEL, URILOOP_GENERATOR_MODEL, …),
5
+ 2. dostawcy (URILOOP_GENERATOR_PROVIDER=ollama|deepseek|subllm),
6
+ 3. telemetrii hosta: VRAM GPU (nvidia-smi lub URILOOP_VRAM_GB), platforma,
7
+ 4. dostępności: klucz API dla zdalnych, zainstalowane modele w Ollama.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ import json
13
+ import os
14
+ import platform
15
+ import shutil
16
+ import subprocess
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ from .http import get_json
21
+
22
+ MODELS_PATH = Path(os.environ.get("URILOOP_MODELS") or
23
+ Path(__file__).resolve().parents[3] / "registry" / "models.json")
24
+
25
+
26
+ @dataclass
27
+ class Telemetry:
28
+ vram_gb: float = 0.0
29
+ gpu: str | None = None
30
+ system: str = platform.system()
31
+ machine: str = platform.machine()
32
+ ollama_models: list = field(default_factory=list)
33
+
34
+ def to_dict(self) -> dict:
35
+ return {"vram_gb": self.vram_gb, "gpu": self.gpu, "system": self.system,
36
+ "machine": self.machine, "ollama_models": self.ollama_models[:50]}
37
+
38
+
39
+ @functools.lru_cache(maxsize=1)
40
+ def _nvidia() -> tuple[float, str | None]:
41
+ if not shutil.which("nvidia-smi"):
42
+ return 0.0, None
43
+ try:
44
+ out = subprocess.run(["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"],
45
+ capture_output=True, text=True, timeout=5, check=True).stdout.strip().splitlines()
46
+ name, mem = out[0].rsplit(",", 1)
47
+ return round(float(mem) / 1024, 1), name.strip()
48
+ except (OSError, subprocess.SubprocessError, ValueError, IndexError):
49
+ return 0.0, None
50
+
51
+
52
+ def ollama_url() -> str:
53
+ return (os.environ.get("OLLAMA_URL") or os.environ.get("OLLAMA_HOST") or "http://127.0.0.1:11434").rstrip("/")
54
+
55
+
56
+ def telemetry(probe_ollama: bool = True) -> Telemetry:
57
+ env_vram = os.environ.get("URILOOP_VRAM_GB")
58
+ vram, gpu = (float(env_vram), "env") if env_vram else _nvidia()
59
+ t = Telemetry(vram_gb=vram, gpu=gpu)
60
+ if probe_ollama:
61
+ try:
62
+ t.ollama_models = [m["name"] for m in get_json(ollama_url() + "/api/tags", timeout=1.5).get("models", [])]
63
+ except Exception: # noqa: BLE001 — brak Ollamy to legalny stan (fallback zero-gen)
64
+ t.ollama_models = []
65
+ return t
66
+
67
+
68
+ @functools.lru_cache(maxsize=4)
69
+ def _load(path: str) -> dict:
70
+ return json.loads(Path(path).read_text(encoding="utf-8"))
71
+
72
+
73
+ def load_models(path: str | Path | None = None) -> dict:
74
+ return _load(str(path or MODELS_PATH))
75
+
76
+
77
+ def _installed(model: str, installed: list) -> bool:
78
+ if not installed:
79
+ return False
80
+ names = set(installed) | {n.removesuffix(":latest") for n in installed}
81
+ return model in names or f"{model}:latest" in names
82
+
83
+
84
+ def base_url(profile: dict) -> str:
85
+ return (os.environ.get(profile.get("base_url_env", "")) or profile.get("default_base_url", "")).rstrip("/")
86
+
87
+
88
+ def select(role: str, t: Telemetry | None = None, *, require_installed: bool = True) -> list[dict]:
89
+ """Zwraca uporządkowaną listę kandydatów-profili (pierwszy = wybrany, reszta = fallback)."""
90
+ spec = load_models()["roles"][role]
91
+ profiles = [dict(p) for p in spec["profiles"]]
92
+ t = t or telemetry()
93
+ override = next((os.environ[e] for e in spec.get("env_override", []) if os.environ.get(e)), None)
94
+ provider = os.environ.get(spec.get("provider_env", ""), "") if spec.get("provider_env") else ""
95
+
96
+ if override:
97
+ hit = [p for p in profiles if override in (p["id"], p.get("model"))]
98
+ if not hit: # model spoza katalogu: dziedziczy backend pierwszego profilu danego dostawcy
99
+ tmpl = next((p for p in profiles if not provider or p.get("provider") == provider), profiles[0])
100
+ hit = [{**tmpl, "id": f"override:{override}", "model": override, "min_vram_gb": 0}]
101
+ rest = [p for p in profiles if p not in hit]
102
+ profiles = hit + rest
103
+
104
+ def usable(p: dict) -> bool:
105
+ if provider and p.get("provider") and p["provider"] != provider and not p["id"].startswith("override:"):
106
+ return False
107
+ if p.get("api_key_env") and p.get("remote") and not os.environ.get(p["api_key_env"]):
108
+ return False
109
+ if p.get("opt_in_env") and not os.environ.get(p["opt_in_env"]) and provider != p.get("provider"):
110
+ return False
111
+ if p.get("backend", "").startswith("ollama"):
112
+ if t.vram_gb and p.get("min_vram_gb", 0) > t.vram_gb and not p["id"].startswith("override:"):
113
+ return False
114
+ if require_installed and t.ollama_models is not None and not _installed(p["model"], t.ollama_models):
115
+ return p["id"].startswith("override:") and bool(t.ollama_models)
116
+ return True
117
+
118
+ ordered = [p for p in profiles if usable(p)]
119
+ # zawsze zostaw deterministyczny fallback, jeśli rola go ma (NUL-022 §2)
120
+ for p in profiles:
121
+ if p.get("backend") == "zero-gen" and p not in ordered:
122
+ ordered.append(p)
123
+ return ordered
124
+
125
+
126
+ def describe(t: Telemetry | None = None) -> dict:
127
+ t = t or telemetry()
128
+ return {"telemetry": t.to_dict(),
129
+ "selected": {role: [p["id"] for p in select(role, t)] for role in load_models()["roles"]}}