From 322577cf9fa54a17bddc1aba605c6516c62b9869 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 23 Aug 2026 15:18:39 +0300 Subject: [PATCH] Add offline analysis and client event tracking to Assistent API - Introduced `/assistent/analyze-reply` endpoint for offline extraction of patches and client predictions without VRAM. - Added `/assistent/client-event` and `/assistent/client-events` endpoints for tracking UI interactions and retrieving event history. - Updated Debug API documentation to reflect new endpoints and their functionalities. - Enhanced Assistent session handling with improved client prediction logic and diagnostics. Co-authored-by: Cursor --- docs/cli.md | 11 +- src/gpu_rent/cli.py | 6 +- src/gpu_rent/debug_api.py | 157 ++++++++++++++++++++++- src/gpu_rent/debug_assistent.py | 158 ++++++++++++++++++++++-- src/gpu_rent/debug_assistent_session.py | 15 +++ src/gpu_rent/provision.py | 122 +++++++++++++++--- tests/test_assistent_session.py | 52 +++++++- tests/test_debug_api.py | 42 +++++++ 8 files changed, 529 insertions(+), 34 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index a174b36..bd5b613 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -163,6 +163,9 @@ Exit 0 → можно `up`. Exit 1 → причина в таблице / кра | `/assistent/session/{id}/chat` | **POST** — ход + объект `trace` | | `/assistent/session/{id}` | **GET** / **DELETE** — состояние / сброс | | `/assistent/chat-eval` | One-shot: session + один chat + delete | +| `/assistent/analyze-reply` | **POST** `{message, reply}` — офлайн extract + `client.would_generate` (без VRAM) | +| `/assistent/client-event` | **POST** — beacon из вкладки Assistent (последний ход UI) | +| `/assistent/client-events` | **GET** `?since=` — кольцо beacon'ов | Симптомы → куда смотреть: поле `playbook` в `/assistent`. @@ -199,7 +202,13 @@ curl -sS -X DELETE "$BASE/assistent/session/$SID" curl -sS -X POST "$BASE/assistent/chat-eval" \ -H 'Content-Type: application/json' \ -d '{"message":"какие steps/cfg для turbo?","persona":"neutral","timeout":120}' \ - | jq '{ok,reply,patch,trace}' + | jq '{ok,reply,patch,client,trace}' + +# Offline: paste a live UI reply (no GPU) +curl -sS -X POST "$BASE/assistent/analyze-reply" \ + -H 'Content-Type: application/json' \ + -d '{"persona":"neutral","message":"а ты знаешь какие то промпты с civitai","reply":"```json\n{\"prompt\":\"x\",\"generate\":true}\n```"}' \ + | jq '{patch,client}' ``` Ответ chat: `ok`, `reply`, `trace` (`timings_ms`, `patch`, `exact_merge`, `compact_context`, `system_chars` / `system_layers`, `skills`, `hops`, `gaps`, `errors`/`warnings`/`hints`). Sqlite SaveChat fail — soft error, если текст ответа есть. diff --git a/src/gpu_rent/cli.py b/src/gpu_rent/cli.py index 80dd818..7900f0b 100644 --- a/src/gpu_rent/cli.py +++ b/src/gpu_rent/cli.py @@ -976,13 +976,17 @@ def pull_output_cmd() -> None: def seed_extensions_cmd() -> None: """Clone/fetch extensions.yaml, затем restart swarmui.""" try: - from gpu_rent.provision import verify_assistent_sqlite_bins + from gpu_rent.provision import ( + repair_assistent_sqlite_bins, + verify_assistent_sqlite_bins, + ) cfg, host = _live() seed_extensions(cfg, host, log) ensure_swarmui_running(cfg, host, log, restart=True) # Build lands Sqlite private deps beside the extension DLL (≥0.13.1). + repair_assistent_sqlite_bins(cfg, host, log) verify_assistent_sqlite_bins(cfg, host, log) except GpuRentError as exc: _die(exc) diff --git a/src/gpu_rent/debug_api.py b/src/gpu_rent/debug_api.py index c980fc4..92fd6f9 100644 --- a/src/gpu_rent/debug_api.py +++ b/src/gpu_rent/debug_api.py @@ -74,13 +74,15 @@ _OPENAPI: dict[str, Any] = { "openapi": "3.0.3", "info": { "title": "gpu-rent Debug API", - "version": "1.2.0", + "version": "1.3.0", "description": ( "Localhost diagnostics for installer progress and " "SwarmUI / Comfy / Ollama / Assistent. Mostly read-only. " "Assistent multi-turn: POST /assistent/session + " "/assistent/session/{id}/chat (trace object). " "One-shot: POST/GET /assistent/chat-eval. " + "Offline reply score: POST /assistent/analyze-reply " + "(no VRAM). " + _WARN_VRAM ), }, @@ -278,6 +280,54 @@ _OPENAPI: dict[str, Any] = { }, } }, + "/assistent/analyze-reply": { + "post": { + "summary": ( + "Offline: extract patch + 0.15.3 client would_generate " + "from a pasted {message, reply}. No Swarm / VRAM." + ), + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["reply"], + "properties": { + "message": {"type": "string"}, + "reply": {"type": "string"}, + "persona": {"type": "string"}, + "pack": {"type": "string"}, + }, + } + } + }, + }, + }, + "get": { + "summary": "Same as POST; query message/reply (short only)", + }, + }, + "/assistent/client-event": { + "post": { + "summary": ( + "Browser beacon of last UI turn (intent, patch, " + "swarm_prompt). CORS localhost. No VRAM." + ), + } + }, + "/assistent/client-events": { + "get": { + "summary": "In-memory UI beacons (query since=seq)", + "parameters": [ + { + "name": "since", + "in": "query", + "schema": {"type": "integer"}, + } + ], + } + }, "/assistent/chat-eval": { "get": { "summary": ( @@ -339,6 +389,10 @@ class DebugHub: step: str = "starting" step_started_at: float = field(default_factory=time.time) _events: deque[dict[str, Any]] = field(default_factory=lambda: deque(maxlen=EVENT_CAPACITY)) + _client_events: deque[dict[str, Any]] = field( + default_factory=lambda: deque(maxlen=80) + ) + _client_seq: int = 0 _seq: int = 0 _lock: threading.Lock = field(default_factory=threading.Lock) _cache: dict[str, tuple[float, Any]] = field(default_factory=dict) @@ -403,6 +457,21 @@ class DebugHub: items = list(self._events) return items[-n:] + def push_client_event(self, event: dict[str, Any]) -> dict[str, Any]: + with self._lock: + self._client_seq += 1 + row = { + "seq": self._client_seq, + "ts": time.time(), + **{k: event[k] for k in event if k != "seq"}, + } + self._client_events.append(row) + return {"ok": True, "seq": row["seq"], "stored": True} + + def client_events_since(self, since: int = 0) -> list[dict[str, Any]]: + with self._lock: + return [e for e in self._client_events if int(e.get("seq") or 0) > since] + def progress(self) -> dict[str, Any]: with self._lock: last = self._events[-1] if self._events else None @@ -490,6 +559,9 @@ def _make_handler(hub: DebugHub): self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") self.end_headers() self.wfile.write(body) @@ -521,7 +593,8 @@ def _make_handler(hub: DebugHub): f"then /snapshot. " f"Opt-in Assistent: /assistent/session (multi-turn) · " f"/assistent/chat-eval (one-shot) — not in snapshot; " - f"may load VRAM.

" + f"may load VRAM. Offline: " + f"/assistent/analyze-reply.

" f"" ) self._send(200, html.encode("utf-8"), "text/html; charset=utf-8") @@ -543,6 +616,14 @@ def _make_handler(hub: DebugHub): raise ValueError("JSON body must be an object") return data + def do_OPTIONS(self) -> None: # noqa: N802 + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.send_header("Content-Length", "0") + self.end_headers() + def do_GET(self) -> None: # noqa: N802 try: self._dispatch("GET") @@ -603,6 +684,8 @@ def _make_handler(hub: DebugHub): return if method == "POST" and path not in { "/assistent/chat-eval", + "/assistent/analyze-reply", + "/assistent/client-event", "/assistent/session", } and not _is_assistent_session_chat(path): self._json( @@ -611,6 +694,7 @@ def _make_handler(hub: DebugHub): "ok": False, "error": ( "POST only for /assistent/chat-eval, " + "/assistent/analyze-reply, /assistent/client-event, " "/assistent/session, /assistent/session/{id}/chat" ), }, @@ -769,6 +853,9 @@ def _make_handler(hub: DebugHub): "/assistent/session", "/assistent/session/{id}", "/assistent/session/{id}/chat", + "/assistent/analyze-reply", + "/assistent/client-event", + "/assistent/client-events", "/assistent/chat-eval", ] @@ -924,6 +1011,70 @@ def _make_handler(hub: DebugHub): skills=skills, persona=body.get("persona"), ) + elif sub == "client-events": + if method != "GET": + self._json(405, {"ok": False, "error": "GET only"}) + return + since = 0 + try: + since = int((qs.get("since") or ["0"])[0]) + except (TypeError, ValueError): + since = 0 + payload = { + "ok": True, + "events": hub.client_events_since(since), + } + elif sub == "client-event": + if method != "POST": + self._json(405, {"ok": False, "error": "POST only"}) + return + try: + body = self._read_json_body() + except ValueError as exc: + self._json(400, {"ok": False, "error": str(exc)}) + return + if not isinstance(body, dict): + self._json(400, {"ok": False, "error": "JSON object required"}) + return + payload = hub.push_client_event(body) + elif sub == "analyze-reply": + body = {} + if method == "POST": + try: + body = self._read_json_body() + except ValueError as exc: + self._json(400, {"ok": False, "error": str(exc)}) + return + elif method != "GET": + self._json(405, {"ok": False, "error": "GET or POST"}) + return + + def _aq(name: str) -> Any: + if name in body and body[name] is not None: + return body[name] + vals = qs.get(name) + return vals[0] if vals else None + + reply_text = _aq("reply") + if not (reply_text or "").strip(): + self._json( + 400, + { + "ok": False, + "error": "reply required", + "usage": ( + "POST {message, reply, persona?, pack?} " + "— offline extract + client.would_generate" + ), + }, + ) + return + payload = debug_assistent.analyze_assistent_reply( + message=_aq("message"), + reply=str(reply_text), + persona=_aq("persona"), + pack=_aq("pack"), + ) elif sub == "chat-eval": body = {} if method == "POST": @@ -1029,6 +1180,8 @@ def start_debug_server( f" {base}/assistent/diagnose", f" {base}/assistent/session (multi-turn; not in snapshot)", f" {base}/assistent/chat-eval (one-shot; not in snapshot)", + f" {base}/assistent/analyze-reply (offline; no VRAM)", + f" {base}/assistent/client-events (UI beacons)", ] if log: for line in lines: diff --git a/src/gpu_rent/debug_assistent.py b/src/gpu_rent/debug_assistent.py index 7975595..88abfd1 100644 --- a/src/gpu_rent/debug_assistent.py +++ b/src/gpu_rent/debug_assistent.py @@ -65,25 +65,28 @@ DATA = Path("/mnt/swarm_data") OPT = Path("/opt/swarmui/src/Extensions") roots = [DATA / "Extensions", OPT] found = [] +dll_seen = set() +for search in (DATA, Path("/opt/swarmui")): + if not search.is_dir(): + continue + for dll in search.rglob("SwarmAssistentExtension.dll"): + if any(p in {"obj", "node_modules"} for p in dll.parts): + continue + dll_seen.add(dll) for root in roots: if not root.is_dir(): continue for p in sorted(root.iterdir()): if "assistent" not in p.name.lower(): continue - # Prefer TFM output dirs (Debug/Release net*), then any bin/** fallback - dlls = [] + dlls = [d for d in dll_seen if p in d.parents or d.parent == p] + # Prefer TFM output dirs (Debug/Release net*), then any match + ranked = [] for cfg_name in ("Debug", "Release"): - dlls.extend(sorted(p.glob(f"bin/{cfg_name}/net*/SwarmAssistentExtension.dll"))) - if not dlls: - dlls = sorted(p.glob("bin/**/SwarmAssistentExtension.dll")) - if not dlls: - # Older layout / alternate assembly name - for cfg_name in ("Debug", "Release"): - dlls.extend(sorted(p.glob(f"bin/{cfg_name}/net*/*Assistent*.dll"))) - if not dlls: - dlls = sorted(p.glob("bin/**/*Assistent*.dll")) - dll = dlls[0] if dlls else None + ranked.extend(sorted(p.glob(f"bin/{cfg_name}/net*/SwarmAssistentExtension.dll"))) + if not ranked: + ranked = sorted(dlls) or sorted(p.glob("bin/**/SwarmAssistentExtension.dll")) + dll = ranked[0] if ranked else None dll_dir = dll.parent if dll else None sqlite_dll = (dll_dir / "Microsoft.Data.Sqlite.dll") if dll_dir else None csproj = next(p.glob("*.csproj"), None) @@ -356,6 +359,137 @@ def _generate_flag_on(obj: dict[str, Any] | None) -> bool: return isinstance(acts, list) and "generate" in [str(a) for a in acts] +# Mirrors swarm-assistent src/intent.js (0.15.2). Keep in lockstep. +_CYR_BOUND = r"(^|[^0-9A-Za-z_А-Яа-яЁё])" +_CYR_END = r"(?=$|[^0-9A-Za-z_А-Яа-яЁё])" +_PERSONA_CHIP = { + "neutral": "Нормальный", + "aggressive": "Агрессивный", + "dreamer": "Мечтатель", +} + + +def _cyr_token_re(alts: str) -> re.Pattern[str]: + return re.compile(f"{_CYR_BOUND}(?:{alts}){_CYR_END}", re.IGNORECASE) + + +def user_asks_no_generate(text: str | None) -> bool: + t = (text or "").strip() + if not t: + return False + if re.search( + r"\b(remember|save\s+(this\s+)?(as\s+)?(the\s+)?(base\s+)?(prompt|template)|" + r"don'?t\s+generat|do\s+not\s+generat|no\s+generat|without\s+generat)\b", + t, + re.I, + ): + return True + return bool( + _cyr_token_re( + r"запомн|запомни|запомним|сохрани|сохраним|шаблон|" + r"базов(ый|ого|ому|ым|ая|ую|ое)?\s+промпт|" + r"не\s+генерир[а-яё]*|без\s+генерац[а-яё]*|не\s+надо\s+генер[а-яё]*|" + r"только\s+запомн[а-яё]*|пока\s+запомн[а-яё]*|" + r"не\s+рисуй|не\s+запускай\s+генер[а-яё]*|" + r"только\s+(ответь|скажи|объясни)" + ).search(t) + ) + + +def user_asks_generate(text: str | None) -> bool: + t = (text or "").strip() + if not t or user_asks_no_generate(t): + return False + if re.search( + r"\b(generat(e|ion)|draw|render|make\s+(an?\s+)?image|run\s+generate)\b", + t, + re.I, + ): + return True + return bool( + _cyr_token_re( + r"сгенер[а-яё]*|нарисуй|нарисуйте|нарисуем|" + r"запусти\s+генер[а-яё]*|" + r"сдела(й|ем|йте)\s+(кадр|картинк[а-яё]*|изображ[а-яё]*)" + ).search(t) + ) + + +def predict_client_turn( + message: str | None, + patch: dict[str, Any] | None, + *, + persona: str | None = None, + pack: str | None = None, +) -> dict[str, Any]: + """What swarm-assistent 0.15.3 JS would do after this HTTP reply. + + Generate is user-owned: model generate:true is advisory. AssistentChat + never starts Swarm Generate. + """ + model_generate = _generate_flag_on(patch) + vetoed = user_asks_no_generate(message) + user_gen = user_asks_generate(message) + has_prompt = bool(str((patch or {}).get("prompt") or "").strip()) + user_asked = user_gen and (model_generate or has_prompt) + would_generate = bool(not vetoed and user_asked) + if vetoed: + reason = "vetoed" + elif user_asked: + reason = "user_phrase" + elif model_generate: + reason = "model_flag_ignored" + else: + reason = "none" + pid = (persona or "").strip() or None + toast = None + if would_generate: + toast = "Промпт обновлён · Generate" if has_prompt else "Запускаю Generate" + elif patch and has_prompt: + toast = None + return { + "user_asks_generate": user_gen, + "user_asks_no_generate": vetoed, + "model_generate": model_generate, + "would_apply_patch": bool(would_generate and patch), + "would_generate": would_generate, + "generate_reason": reason, + "http_starts_generate": False, + "toast": toast, + "persona": pid, + "persona_chip": _PERSONA_CHIP.get(pid or "", pid) if pid else None, + "pack": (pack or "").strip() or None, + "note": ( + "0.15.3: HTTP AssistentChat never runs Generate. " + "would_generate mirrors resolveTurnIntent — user «нарисуй»/" + "«сгенерируй»/«сделаем изображение», not model generate:true alone." + ), + } + + +def analyze_assistent_reply( + *, + message: str | None = None, + reply: str | None = None, + persona: str | None = None, + pack: str | None = None, +) -> dict[str, Any]: + """Offline extract + 0.15.2 client prediction. No Swarm / VRAM.""" + extracted = extract_assistent_patch(reply) + patch = extracted.get("patch") + client = predict_client_turn(message, patch, persona=persona, pack=pack) + return { + "ok": True, + "offline": True, + "message": message or "", + "reply": reply or "", + "reply_prose": extracted.get("prose") or "", + "patch": _summarize_patch(patch), + "client": client, + "note": "No AssistentChat — paste a live UI reply to see what 0.15.2 would apply/Generate.", + } + + def _normalize_extracted_patch(obj: dict[str, Any]) -> dict[str, Any]: patch = dict(obj) if _generate_flag_on(patch): diff --git a/src/gpu_rent/debug_assistent_session.py b/src/gpu_rent/debug_assistent_session.py index 0c289a3..78e8018 100644 --- a/src/gpu_rent/debug_assistent_session.py +++ b/src/gpu_rent/debug_assistent_session.py @@ -37,6 +37,7 @@ from gpu_rent.debug_assistent import ( collect_assistent_deep, extract_assistent_patch, _generate_flag_on, + predict_client_turn, ) SESSION_TTL_SEC = 45 * 60 @@ -56,6 +57,10 @@ _TRACE_GAPS = [ "Park/Warm not run on chat turns (AssistentParkLlm/WarmLlm are Generate-path)", "Sqlite SaveChat is client-side; AssistentChat may still return reply when " "memory DLL is missing — soft error in trace.errors", + "HTTP AssistentChat never starts Swarm Generate; trace.client.would_generate " + "is a 0.15.3 JS simulation (user draw phrase, not model generate:true). " + "Live UI last turn: GET /assistent/client-events (JS beacon) or " + "POST /assistent/analyze-reply. ListChats/GetUiState need Sqlite DLL.", ] @@ -417,6 +422,7 @@ def build_chat_trace( errors: list[str], warnings: list[str], hints: list[str], + message: str | None = None, ) -> dict[str, Any]: extracted = extract_assistent_patch(reply) patch = extracted.get("patch") @@ -465,7 +471,13 @@ def build_chat_trace( elif raw is not None: raw_preview = str(raw)[:400] + client = predict_client_turn(message, patch, persona=persona, pack=pack) out_hints = list(hints) + list(exact_merge.get("hints") or []) + if client.get("model_generate") and not client.get("user_asks_generate"): + out_hints.append( + "model generate:true without user draw phrase — 0.15.3 will NOT " + "auto-Generate; UI shows JSON card + Сгенерировать" + ) out: dict[str, Any] = { "ok": ok, "timings_ms": timings_ms, @@ -498,6 +510,7 @@ def build_chat_trace( "reply": reply, "reply_prose": extracted.get("prose"), "patch": _summarize_patch(patch), + "client": client, "exact_merge": exact_merge, "park_warm": { "invoked": False, @@ -876,6 +889,7 @@ def chat_debug_session( errors=errors, warnings=warnings, hints=hints, + message=text, ) session.last_trace = trace session.touch() @@ -1114,6 +1128,7 @@ def run_assistent_chat_eval_via_session( "reply": result.get("reply"), "reply_prose": trace.get("reply_prose"), "patch": trace.get("patch"), + "client": trace.get("client"), "raw": trace.get("raw"), "system_chars": trace.get("system_chars"), "system_layers": trace.get("system_layers"), diff --git a/src/gpu_rent/provision.py b/src/gpu_rent/provision.py index bd3ecc9..126e4ab 100644 --- a/src/gpu_rent/provision.py +++ b/src/gpu_rent/provision.py @@ -229,34 +229,87 @@ print(__import__("json").dumps(found)) _ASSISTENT_SQLITE_BIN_PY = r""" from pathlib import Path import json -roots = [Path("/mnt/swarm_data/Extensions"), Path("/opt/swarmui/src/Extensions")] +roots = [Path("/mnt/swarm_data"), Path("/opt/swarmui")] +seen = set() found = [] for root in roots: if not root.is_dir(): continue - for p in sorted(root.iterdir()): - if "assistent" not in p.name.lower(): + for dll in root.rglob("SwarmAssistentExtension.dll"): + if any(p in {"obj", "node_modules"} for p in dll.parts): continue - dlls = [] - for cfg_name in ("Debug", "Release"): - dlls.extend(sorted(p.glob(f"bin/{cfg_name}/net*/SwarmAssistentExtension.dll"))) - if not dlls: - dlls = sorted(p.glob("bin/**/SwarmAssistentExtension.dll")) - dll = dlls[0] if dlls else None - dll_dir = dll.parent if dll else None - sqlite = (dll_dir / "Microsoft.Data.Sqlite.dll") if dll_dir else None - pcl = list(dll_dir.glob("SQLitePCLRaw*.dll")) if dll_dir else [] + key = str(dll) + if key in seen: + continue + seen.add(key) + dll_dir = dll.parent + sqlite = dll_dir / "Microsoft.Data.Sqlite.dll" + pcl = list(dll_dir.glob("SQLitePCLRaw*.dll")) found.append({ - "path": str(p), - "name": p.name, - "dll": str(dll) if dll else None, - "dll_dir": str(dll_dir) if dll_dir else None, - "sqlite_dll": bool(sqlite and sqlite.is_file()), + "path": str(dll.parent.parent) if dll.parent.name.startswith("net") else str(dll_dir), + "name": "swarm-assistent", + "dll": str(dll), + "dll_dir": str(dll_dir), + "sqlite_dll": sqlite.is_file(), "sqlitepcl": bool(pcl), }) print(json.dumps(found)) """ +_ASSISTENT_SQLITE_REPAIR_PY = r""" +from pathlib import Path +import json, shutil +skip = {"obj", "node_modules"} +dlls = [] +for root in (Path("/mnt/swarm_data"), Path("/opt/swarmui")): + if not root.is_dir(): + continue + for dll in root.rglob("SwarmAssistentExtension.dll"): + if any(p in skip for p in dll.parts): + continue + dlls.append(dll) + +def has_sqlite(d): + return (d / "Microsoft.Data.Sqlite.dll").is_file() and list(d.glob("SQLitePCLRaw*.dll")) + +donors = [d.parent for d in dlls if has_sqlite(d.parent)] +if not donors: + nuget = Path.home() / ".nuget" / "packages" + extra = [] + if nuget.is_dir(): + extra.extend(nuget.rglob("Microsoft.Data.Sqlite.dll")) + extra.extend(nuget.rglob("SQLitePCLRaw.core.dll")) + # last resort: any copy on the disk under swarm + for root in (Path("/opt/swarmui"), Path("/root/.nuget")): + if root.is_dir(): + extra.extend(root.rglob("Microsoft.Data.Sqlite.dll")) + for f in extra: + if f.parent not in donors: + donors.append(f.parent) + +copied = [] +missing = [] +for dll in dlls: + dest = dll.parent + if has_sqlite(dest): + continue + src = next((d for d in donors if d != dest and has_sqlite(d)), None) + if src is None: + src = next((d for d in donors if (d / "Microsoft.Data.Sqlite.dll").is_file()), None) + if src is None: + missing.append(str(dest)) + continue + names = ["Microsoft.Data.Sqlite.dll"] + names += [p.name for p in src.glob("SQLitePCLRaw*.dll")] + names += [p.name for p in src.glob("e_sqlite3.*")] + for name in names: + fp = src / name + if fp.is_file(): + shutil.copy2(fp, dest / name) + copied.append({"dll_dir": str(dest), "from": str(src)}) +print(json.dumps({"copied": copied, "missing": missing, "dlls": [str(d) for d in dlls]})) +""" + def _log_assistent_source_sqlite_hint(cfg: Config, host: str, log: Log) -> None: """After clone: warn if Assistent csproj lacks Sqlite private-dep wiring (≥0.13.1).""" @@ -298,6 +351,40 @@ def _log_assistent_source_sqlite_hint(cfg: Config, host: str, log: Log) -> None: ) +def repair_assistent_sqlite_bins(cfg: Config, host: str, log: Log) -> bool: + """Copy Microsoft.Data.Sqlite + SQLitePCLRaw next to every loaded Assistent DLL.""" + try: + raw = run_ssh( + cfg, + host, + "python3 - <<'PY'\n" + _ASSISTENT_SQLITE_REPAIR_PY + "\nPY", + check=False, + timeout=40, + ).strip() + if not raw: + return False + data = json.loads(raw.splitlines()[-1]) + except Exception as exc: + log(f"⚠ Assistent Sqlite repair: {exc}") + return False + copied = data.get("copied") or [] + missing = data.get("missing") or [] + dlls = data.get("dlls") or [] + if not dlls: + log("⚠ Assistent Sqlite repair: SwarmAssistentExtension.dll не найден — compile/restart") + return False + for row in copied: + log(f"Assistent Sqlite: скопировал рядом с {row.get('dll_dir')}") + for path in missing: + log(f"⚠ Assistent Sqlite: нет donor DLL для {path}") + if copied: + return True + if not missing: + log("Assistent Sqlite: private deps уже рядом с DLL") + return True + return False + + def verify_assistent_sqlite_bins(cfg: Config, host: str, log: Log) -> bool: """After SwarmUI build/restart: Microsoft.Data.Sqlite (+ SQLitePCLRaw) beside extension DLL.""" try: @@ -1630,6 +1717,7 @@ def provision_vm( if swarm: ensure_swarmui_running(cfg, host, log, restart=restart) try: + repair_assistent_sqlite_bins(cfg, host, log) verify_assistent_sqlite_bins(cfg, host, log) except Exception as exc: log(f"Assistent Sqlite check: {exc}") diff --git a/tests/test_assistent_session.py b/tests/test_assistent_session.py index 46f8642..3d8933c 100644 --- a/tests/test_assistent_session.py +++ b/tests/test_assistent_session.py @@ -4,7 +4,12 @@ from __future__ import annotations import time -from gpu_rent.debug_assistent import extract_assistent_patch +from gpu_rent.debug_assistent import ( + analyze_assistent_reply, + extract_assistent_patch, + predict_client_turn, + user_asks_generate, +) from gpu_rent import debug_assistent_session as das @@ -33,6 +38,51 @@ def test_extract_assistent_patch_ignores_non_patch_json(): assert out["patch"] is None +def test_predict_client_turn_civitai_question_no_patch(): + pred = predict_client_turn("а ты знаешь какие то промпты с civitai", None) + assert pred["user_asks_generate"] is False + assert pred["would_generate"] is False + assert pred["http_starts_generate"] is False + assert pred["generate_reason"] == "none" + + +def test_predict_client_turn_model_flag_without_user_phrase(): + pred = predict_client_turn( + "а ты знаешь какие то промпты с civitai", + {"prompt": "a redhead", "generate": True}, + persona="neutral", + ) + assert pred["user_asks_generate"] is False + assert pred["model_generate"] is True + assert pred["would_generate"] is False + assert pred["generate_reason"] == "model_flag_ignored" + assert pred["persona_chip"] == "Нормальный" + + +def test_predict_client_turn_narisuy_with_prompt(): + pred = predict_client_turn("нарисуй лису", {"prompt": "a fox"}) + assert pred["user_asks_generate"] is True + assert pred["would_generate"] is True + assert pred["generate_reason"] == "user_phrase" + assert user_asks_generate("нарисуй лису") is True + assert user_asks_generate("Давай сделаем изображение рыжей") is True + assert user_asks_generate("что ты умеешь") is False + + +def test_analyze_assistent_reply_offline(): + out = analyze_assistent_reply( + message="сгенерируй", + reply='ok\n```json\n{"prompt":"a cat","generate":true}\n```\n', + persona="leonid", + pack="ordinary", + ) + assert out["ok"] is True + assert out["offline"] is True + assert out["patch"]["generate"] is True + assert out["client"]["would_generate"] is True + assert out["client"]["generate_reason"] == "user_phrase" + + def test_analyze_exact_merge_fills_omitted_params(): patch = {"actions": ["generate"], "prompt": "x"} exact = { diff --git a/tests/test_debug_api.py b/tests/test_debug_api.py index b614e2b..206bdc5 100644 --- a/tests/test_debug_api.py +++ b/tests/test_debug_api.py @@ -145,7 +145,49 @@ def test_debug_server_routes(monkeypatch, tmp_path): assert "/assistent/extension" in data["paths"] assert "/assistent/api" in data["paths"] assert "/assistent/chat-eval" in data["paths"] + assert "/assistent/analyze-reply" in data["paths"] assert "post" in data["paths"]["/assistent/chat-eval"] + assert "post" in data["paths"]["/assistent/analyze-reply"] + + req = urllib.request.Request( + f"http://127.0.0.1:{port}/assistent/analyze-reply", + data=json.dumps( + { + "message": "а ты знаешь какие то промпты с civitai", + "reply": '```json\n{"prompt":"x","generate":true}\n```', + "persona": "neutral", + } + ).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=5) as resp: + scored = json.loads(resp.read().decode("utf-8")) + assert scored["ok"] is True + assert scored["client"]["would_generate"] is False + assert scored["client"]["user_asks_generate"] is False + assert scored["client"]["model_generate"] is True + assert scored["client"]["persona_chip"] == "Нормальный" + + beacon = urllib.request.Request( + f"http://127.0.0.1:{port}/assistent/client-event", + data=json.dumps( + { + "user": "а ты знаешь какие то промпты с civitai", + "generating": False, + "intent": {"generate": False, "modelAsked": True}, + "swarm_prompt": "old prompt", + } + ).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(beacon, timeout=5) as resp: + stored = json.loads(resp.read().decode("utf-8")) + assert stored["ok"] is True + code, data = get("/assistent/client-events") + assert data["ok"] is True + assert data["events"][-1]["generating"] is False code, data = get("/assistent/chat-eval") assert "ok" in data