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 +2 -0
- uriloop/__main__.py +5 -0
- uriloop/ai/__init__.py +0 -0
- uriloop/ai/decision.py +305 -0
- uriloop/ai/generator.py +153 -0
- uriloop/ai/http.py +35 -0
- uriloop/ai/router.py +129 -0
- uriloop/ai/vision.py +181 -0
- uriloop/analysis/__init__.py +0 -0
- uriloop/analysis/dom_probe.js +108 -0
- uriloop/analysis/file_transfer.js +53 -0
- uriloop/analysis/layers.py +211 -0
- uriloop/cli.py +247 -0
- uriloop/composites.py +150 -0
- uriloop/doctor.py +111 -0
- uriloop/drivers/__init__.py +0 -0
- uriloop/drivers/cdp.py +234 -0
- uriloop/drivers/vnc.py +227 -0
- uriloop/eventstore.py +236 -0
- uriloop/files.py +76 -0
- uriloop/loop.py +188 -0
- uriloop/mcp/__init__.py +0 -0
- uriloop/mcp/server.py +191 -0
- uriloop/nl.py +127 -0
- uriloop/privacy.py +55 -0
- uriloop/projector.py +147 -0
- uriloop/registry.py +93 -0
- uriloop/runtime.py +675 -0
- uriloop/scenarios.py +240 -0
- uriloop/shell.py +102 -0
- uriloop/suites.py +96 -0
- uriloop/tabs.py +102 -0
- uriloop/targeting.py +67 -0
- uriloop/todo.py +104 -0
- uriloop/uri.py +124 -0
- uriloop-0.3.1.dist-info/METADATA +307 -0
- uriloop-0.3.1.dist-info/RECORD +41 -0
- uriloop-0.3.1.dist-info/WHEEL +5 -0
- uriloop-0.3.1.dist-info/entry_points.txt +3 -0
- uriloop-0.3.1.dist-info/licenses/LICENSE +11 -0
- uriloop-0.3.1.dist-info/top_level.txt +1 -0
uriloop/__init__.py
ADDED
uriloop/__main__.py
ADDED
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))
|
uriloop/ai/generator.py
ADDED
|
@@ -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"]}}
|