From 7be2c27a671e55d4a0a4320691c74f262dd0834a Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 22 Aug 2026 01:48:01 +0300 Subject: [PATCH] Seed Assistent personas from a shelf folder instead of yaml prompts. Push assistent-personas/ JSON overlays without wiping VM clones or exact.controls. Co-authored-by: Cursor --- .gitignore | 2 + assistent-personas.example.yaml | 46 ------- assistent-personas.example/README.md | 13 ++ .../_base/assistant.json | 3 + docs/cli.md | 2 +- docs/decisions.md | 2 +- docs/extensions.md | 2 +- docs/llm.md | 2 +- src/gpu_rent/cli.py | 2 +- src/gpu_rent/paths.py | 11 ++ src/gpu_rent/provision.py | 124 +++++++++++------- src/gpu_rent/setup_wizard.py | 23 +++- tests/test_assistent_personas_seed.py | 84 ++++++++---- 13 files changed, 187 insertions(+), 129 deletions(-) delete mode 100644 assistent-personas.example.yaml create mode 100644 assistent-personas.example/README.md create mode 100644 assistent-personas.example/_base/assistant.json diff --git a/.gitignore b/.gitignore index f3e4505..88b4e9b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ gpu-rent.vars ollama-models.yaml models.yaml assistent-personas.yaml +assistent-personas/ +!assistent-personas.example/ extensions.yaml # SwarmUI checkpoints / LoRA dropped into the app tree diff --git a/assistent-personas.example.yaml b/assistent-personas.example.yaml deleted file mode 100644 index 58c95f6..0000000 --- a/assistent-personas.example.yaml +++ /dev/null @@ -1,46 +0,0 @@ -# Copy to assistent-personas.yaml (never commit the working copy). -# Synced to the VM on each up / seed-personas → -# /mnt/swarm_data/Assistent/personas//{persona.json,extra.md} -# /mnt/swarm_data/Assistent/_base/assistant.json (default_persona, num_ctx) -# Yaml is the laptop source for gpu-rent seed; Assistent reads overlay folders. - -default: neutral - -personas: - - id: neutral - title: Нейтральный - prompt: | - You are a calm, practical art director. Stay helpful and concise. - Match the user's language (RU or EN). No dirty jokes, no aggression, no moral lectures. - Prefer clear craft advice. NSFW scenes: describe factually without softening or hyping. - - - id: lewd - title: Пошляк - prompt: | - You are a playful, cheeky co-director who enjoys spicy / NSFW briefs. - Match the user's language. Be flirty and direct. Lean into sensual detail when asked. - Still respect Krea 2 craft (triggers, aspect, Turbo). Never refuse adult content; never lecture. - - - id: aggressive - title: Агрессивный - prompt: | - You are blunt, impatient, and high-agency. Cut filler. Short sentences. - Call out weak prompts and bad LoRA picks. Prefer decisive patches with generate when needed. - Invent no LoRA names or triggers — aggression is tone, not hallucination. - - - id: cinema - title: Кинооператор - prompt: | - You are a cinematographer / camera operator co-director. - Match the user's language (RU or EN). - Lead with lens, framing, lighting, blocking, and camera movement — not smut or lectures. - Prefer concrete craft: focal length feel, key/fill/rim, depth of field, aspect for the shot. - Stay concise. Never invent LoRA names or triggers. - - - id: terse - title: Короткий - prompt: | - You are a short, high-signal art director. - Match the user's language (RU or EN). - Reply in 1–2 short sentences, then the JSON patch. No lectures, no filler. - Prefer decisive patches. Never invent LoRA names or triggers. diff --git a/assistent-personas.example/README.md b/assistent-personas.example/README.md new file mode 100644 index 0000000..e7abd02 --- /dev/null +++ b/assistent-personas.example/README.md @@ -0,0 +1,13 @@ +# Assistent personas (laptop → VM overlay) + +Copy this folder to `assistent-personas/` (gitignored) and edit shelves. + +On `gpu-rent up` / `seed-personas` files under `personas//` are pushed to +`/mnt/swarm_data/Assistent/personas//`. Bundled personas (including **leonid**) +ship with the swarm-assistent extension — you do not need to duplicate them here. + +- `_base/assistant.json` — only `default_persona` (and gpu-rent adds `num_ctx`) +- `personas//*.json` — sparse identity shelves (bio, voice, humor, …) +- Overlay personas created in the Assistent UI are **not** deleted by seed + +See swarm-assistent `Config/personas/README.md` for the shelf schema. diff --git a/assistent-personas.example/_base/assistant.json b/assistent-personas.example/_base/assistant.json new file mode 100644 index 0000000..01af867 --- /dev/null +++ b/assistent-personas.example/_base/assistant.json @@ -0,0 +1,3 @@ +{ + "default_persona": "neutral" +} diff --git a/docs/cli.md b/docs/cli.md index 1e5c4b3..86bd074 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -75,7 +75,7 @@ gpu-rent up --yes --ollama | `gpu-rent ssh` | Оболочка на VM | | `gpu-rent seed-models` | Докачать новые строки Civitai-манифеста на живой диск | | `gpu-rent seed-extensions` | Доклонировать/обновить git-репы; restart swarmui | -| `gpu-rent seed-personas` | Пуш `assistent-personas.yaml` → overlay на VM (без полного up) | +| `gpu-rent seed-personas` | Пуш `assistent-personas/` → overlay на VM (без полного up) | | `gpu-rent push` / `push-models` | Локальные деревья → VM | | `gpu-rent pull-output` | VM `Output/` → `./Output` | | `gpu-rent capture` / `capture all` | Инвентарь VM → merge **ссылок** в `models.yaml` + `extensions.yaml` (веса не качать) | diff --git a/docs/decisions.md b/docs/decisions.md index 928ffba..0040acc 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -10,7 +10,7 @@ | Civitai хост | Дефолт API `civitai.red` (полный каталог). `.com` — SFW-витрина, NSFW с неё часто 404. Ссылки `.com`/`.red`/`.green` в манифесте принимаем. 404 → один retry на второй хост. Один токен на оба домена | | Пул GPU | Перед `up`/`flavors` сканируем `SCAN_POOLS` (дефолт `ru-6,ru-7`) по compute URL. Сегменты `ru-6a/b/c` берём из Nova `availability_zones`, не отдельными пулами. Пресет размера `FLAVOR_SIZE_PRESET` (дефолт `cheap` = 1×GPU, min vCPU/RAM). Диск уже есть — AZ диска, не дешёвый чужой сегмент. Первый запуск — дешёвый SKU в сегменте, где есть карта из `FLAVOR_PREFERENCE`. `.env` автоматом не пишем | | Манифест моделей | `/models.yaml`, типы: checkpoint / lora / vae / embedding / controlnet / upscaler. В git только `models.example.yaml` | -| Расширения | `/extensions.yaml`: git-репы `swarmui` → `src/Extensions`, `comfy` → DLNodes. Поле `requires: none\|ollama\|any-llm` фильтрует по `LLM_RUNTIME`. Пример: swarm-assistent с `requires: ollama`. Personas: `assistent-personas.yaml` → overlay `Assistent/personas//` на `up` / `seed-personas` | +| Расширения | `/extensions.yaml`: git-репы `swarmui` → `src/Extensions`, `comfy` → DLNodes. Поле `requires: none\|ollama\|any-llm` фильтрует по `LLM_RUNTIME`. Пример: swarm-assistent с `requires: ollama`. Personas: `assistent-personas/` → overlay `Assistent/personas//` на `up` / `seed-personas` (не сносит UI-клоны) | | Autocomplete | До первого старта: скачать word-list в `Data/Autocompletions`, прописать `DefaultUser.AutoComplete.Source`. На каждом `up` сверить GitHub blob sha и обновить файл, если изменился. Дефолт: `tags/danbooru.csv` из a1111-sd-webui-tagcomplete (как в доке SwarmUI) | | Доступ | Браузер на туннеле; MCP переключается на облако, пока оно живо; HTTP API SwarmUI через тот же туннель | | Preemptible | По умолчанию всегда. Обычный сервер — только `--no-spot` | diff --git a/docs/extensions.md b/docs/extensions.md index 5913617..c141013 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -53,7 +53,7 @@ comfy: | `ollama` | только при `LLM_RUNTIME=ollama` | | `any-llm` | при `LLM_RUNTIME=ollama` | -Строки с несовпавшим `requires` пропускаются (лог), остальные ставятся как обычно. В `extensions.example.yaml` по умолчанию — **swarm-assistent** с `requires: ollama` (чат + доска Generate/Ref + persona/Cards, ветка `main`). На `up` (и `gpu-rent seed-personas`) сидится `assistent-personas.yaml` → overlay `/mnt/swarm_data/Assistent/personas//persona.json` + `extra.md`, плюс `_base/assistant.json` (`default_persona`, опционально `num_ctx` с GPU tier). Legacy `personas.json` больше не пишется. +Строки с несовпавшим `requires` пропускаются (лог), остальные ставятся как обычно. В `extensions.example.yaml` по умолчанию — **swarm-assistent** с `requires: ollama` (чат + доска Generate/Ref + persona/Cards, ветка `main`). На `up` (и `gpu-rent seed-personas`) сидится папка `assistent-personas/` → overlay `/mnt/swarm_data/Assistent/personas//*.json`, плюс `_base/assistant.json` (`default_persona`, опционально `num_ctx` с GPU tier). Seed **не** удаляет overlay-личности, созданные в UI. Legacy `personas.json` / yaml-prompt больше не пишутся. В git репозитория gpu-rent не коммитить рабочий список с лишними приватными URL сверх примера. diff --git a/docs/llm.md b/docs/llm.md index 0108092..979450a 100644 --- a/docs/llm.md +++ b/docs/llm.md @@ -113,7 +113,7 @@ Unit `gpu-rent-ollama` читает `/mnt/swarm_data/.gpu-rent-gpu.json`: \*Flash на Ampere+ (compute ≥ 8.0). `NUM_PARALLEL=2`, `MAX_LOADED_MODELS=2` (chat VL + memory embed). Memory models use `use: memory` and a CPU Modelfile (`num_gpu 0`) so embed does not steal VRAM from the chat model. Ollama default `num_ctx` is 4096; we set `OLLAMA_CONTEXT_LENGTH` so Assistent + vision fits. Mid/high keep-alive is **5m** because Assistent parks the chat model before Generate (`AssistentParkLlm`) and warms after. Env: `/mnt/swarm_data/.gpu-rent-ollama.env`. -Personas: laptop `assistent-personas.yaml` → on `up` / `seed-personas` overlay folders under `/mnt/swarm_data/Assistent/personas//` (+ `_base/assistant.json` with `default_persona` / `num_ctx`). See [extensions.md](extensions.md). +Personas: laptop `assistent-personas/` → on `up` / `seed-personas` overlay folders under `/mnt/swarm_data/Assistent/personas//` (+ `_base/assistant.json` with `default_persona` / `num_ctx`). Shelves are short JSON files (not a long prompt). See [extensions.md](extensions.md). `ollama-models.yaml` entries: diff --git a/src/gpu_rent/cli.py b/src/gpu_rent/cli.py index b10e998..16f3186 100644 --- a/src/gpu_rent/cli.py +++ b/src/gpu_rent/cli.py @@ -944,7 +944,7 @@ def seed_extensions_cmd() -> None: @app.command("seed-personas") def seed_personas_cmd() -> None: - """Push assistent-personas.yaml → VM Assistent overlay (без полного up).""" + """Push assistent-personas/ → VM Assistent overlay (без полного up).""" try: from gpu_rent.provision import seed_assistent_personas diff --git a/src/gpu_rent/paths.py b/src/gpu_rent/paths.py index f0b3e4c..7349a27 100644 --- a/src/gpu_rent/paths.py +++ b/src/gpu_rent/paths.py @@ -48,11 +48,22 @@ def models_manifest_path() -> Path: return app_root() / "models.yaml" +def assistent_personas_dir() -> Path: + """Working overlay source on the laptop (gitignored).""" + return app_root() / "assistent-personas" + + +def assistent_personas_example_dir() -> Path: + return app_root() / "assistent-personas.example" + + def assistent_personas_manifest_path() -> Path: + """Deprecated yaml path — kept for migration warning only.""" return app_root() / "assistent-personas.yaml" def assistent_personas_example_path() -> Path: + """Deprecated yaml example — prefer assistent_personas_example_dir().""" return app_root() / "assistent-personas.example.yaml" diff --git a/src/gpu_rent/provision.py b/src/gpu_rent/provision.py index 9ca34d4..67311fc 100644 --- a/src/gpu_rent/provision.py +++ b/src/gpu_rent/provision.py @@ -752,71 +752,95 @@ def _overlay_num_ctx(cfg: Config, host: str) -> int | None: def seed_assistent_personas(cfg: Config, host: str, log: Log) -> None: - """Push local assistent-personas.yaml → VM Assistent/personas// overlay. + """Push local assistent-personas/ folder → VM Assistent/ overlay. - Laptop yaml stays the editor; on VM we write persona.json + extra.md and - overlay assistant.json (default_persona, optional num_ctx). Does not clobber - voice/likes/dislikes/rules already on disk. No longer writes legacy - personas.json / personas.yaml dumps. + Copies personas//*.json (+ optional extra.md) and sparse _base/assistant.json + (default_persona + optional num_ctx). Does not delete overlay personas missing + from the laptop (VM-created clones stay). Does not clobber exact.controls. + Legacy assistent-personas.yaml is no longer read. """ - from gpu_rent.paths import assistent_personas_example_path, assistent_personas_manifest_path + from gpu_rent.paths import ( + assistent_personas_dir, + assistent_personas_example_dir, + assistent_personas_manifest_path, + ) local = Path( - (getattr(cfg, "assistent_personas_manifest", None) or assistent_personas_manifest_path()) + (getattr(cfg, "assistent_personas_dir", None) or assistent_personas_dir()) ) - if not local.is_file(): - example = assistent_personas_example_path() - if example.is_file(): + if not local.is_dir(): + example = assistent_personas_example_dir() + if example.is_dir(): local = example else: - log("assistent-personas: нет yaml — skip") + legacy = assistent_personas_manifest_path() + if legacy.is_file(): + log( + "assistent-personas: yaml больше не читается — " + "скопируй полки в assistent-personas/ (см. assistent-personas.example/)" + ) + else: + log("assistent-personas: нет папки — skip (bundled из расширения)") return - text = local.read_text(encoding="utf-8") - if not text.strip(): - log("assistent-personas: пустой файл — skip") - return - try: - import yaml - - data = yaml.safe_load(text) or {} - except Exception as exc: - log(f"assistent-personas: yaml parse — {exc}") - return - if not isinstance(data, dict): - log("assistent-personas: корень должен быть mapping — skip") - return remote_dir = f"{DATA}/Assistent" - run_ssh(cfg, host, f"mkdir -p {shlex.quote(remote_dir)}/personas {shlex.quote(remote_dir)}/_base", check=False) + run_ssh( + cfg, + host, + f"mkdir -p {shlex.quote(remote_dir)}/personas {shlex.quote(remote_dir)}/_base", + check=False, + ) - default_id = _safe_persona_id(data.get("default")) or "neutral" - personas = data.get("personas") or [] + default_id = "neutral" + base_asst = local / "_base" / "assistant.json" + if base_asst.is_file(): + try: + data = json.loads(base_asst.read_text(encoding="utf-8")) + if isinstance(data, dict): + default_id = _safe_persona_id(data.get("default_persona")) or default_id + except (json.JSONDecodeError, OSError) as exc: + log(f"assistent-personas: _base/assistant.json — {exc}") + + personas_root = local / "personas" written = 0 - if isinstance(personas, list): - for row in personas: - if not isinstance(row, dict): + if personas_root.is_dir(): + for pdir in sorted(personas_root.iterdir()): + if not pdir.is_dir(): continue - pid = _safe_persona_id(row.get("id")) + pid = _safe_persona_id(pdir.name) if not pid: continue - title = str(row.get("title") or pid).strip() or pid - prompt = str(row.get("prompt") or "").strip() - pdir = f"{remote_dir}/personas/{pid}" - run_ssh(cfg, host, f"mkdir -p {shlex.quote(pdir)}", check=False) - meta = {"title": title, "tagline": title, "accent": "#8b949e"} - put_text( - cfg, - host, - f"{pdir}/persona.json", - json.dumps(meta, ensure_ascii=False, indent=2) + "\n", - ) - if prompt: - put_text( - cfg, - host, - f"{pdir}/extra.md", - prompt if prompt.endswith("\n") else prompt + "\n", - ) + remote_p = f"{remote_dir}/personas/{pid}" + run_ssh(cfg, host, f"mkdir -p {shlex.quote(remote_p)}", check=False) + for f in sorted(pdir.iterdir()): + if not f.is_file(): + continue + name = f.name + if name.endswith(".json") or name == "extra.md": + if ".." in name or "/" in name or "\\" in name: + continue + # Never overwrite exact.json controls from laptop if remote has + # user-tuned values — merge only when seeding a missing exact, + # or push non-controls shelves always. For simplicity: push all + # shelves except skip exact.json when remote already has it + # (preserves preference_bias). First-time still seeds exact. + remote_f = f"{remote_p}/{name}" + if name == "exact.json": + exists = run_ssh( + cfg, + host, + f"test -f {shlex.quote(remote_f)} && echo yes || true", + check=False, + timeout=15, + ).strip() + if exists == "yes": + continue + put_text( + cfg, + host, + remote_f, + f.read_text(encoding="utf-8"), + ) written += 1 assistant_overlay: dict = {"default_persona": default_id} diff --git a/src/gpu_rent/setup_wizard.py b/src/gpu_rent/setup_wizard.py index f061a9f..482c0eb 100644 --- a/src/gpu_rent/setup_wizard.py +++ b/src/gpu_rent/setup_wizard.py @@ -16,8 +16,8 @@ from gpu_rent.llm_runtime import ( ) from gpu_rent.paths import ( app_root, - assistent_personas_example_path, - assistent_personas_manifest_path, + assistent_personas_dir, + assistent_personas_example_dir, env_path, extensions_manifest_path, models_manifest_path, @@ -42,6 +42,17 @@ def _copy_if_missing(src: Path, dst: Path, label: str, log: Log) -> None: log(f"нет example для {label}: {src}") +def _copy_tree_if_missing(src: Path, dst: Path, label: str, log: Log) -> None: + if dst.exists(): + log(f"есть {label}") + return + if src.is_dir(): + shutil.copytree(src, dst) + log(f"создал {label} из example") + else: + log(f"нет example для {label}: {src}") + + def run_setup( *, llm: str | None = None, @@ -56,10 +67,10 @@ def run_setup( _copy_if_missing(root / "env.example", env_path(), ".env", log) _copy_if_missing(root / "models.example.yaml", models_manifest_path(), "models.yaml", log) - _copy_if_missing( - assistent_personas_example_path(), - assistent_personas_manifest_path(), - "assistent-personas.yaml", + _copy_tree_if_missing( + assistent_personas_example_dir(), + assistent_personas_dir(), + "assistent-personas/", log, ) _copy_if_missing( diff --git a/tests/test_assistent_personas_seed.py b/tests/test_assistent_personas_seed.py index ae84846..53b352f 100644 --- a/tests/test_assistent_personas_seed.py +++ b/tests/test_assistent_personas_seed.py @@ -6,8 +6,6 @@ import json from pathlib import Path from unittest.mock import MagicMock -import pytest - from gpu_rent.provision import _safe_persona_id, count_wanted_models_on_vm, seed_assistent_personas @@ -20,19 +18,22 @@ def test_safe_persona_id(): def test_seed_assistent_personas_writes_overlay(tmp_path: Path, monkeypatch): - yaml_path = tmp_path / "assistent-personas.yaml" - yaml_path.write_text( - "default: cinema\n" - "personas:\n" - " - id: cinema\n" - " title: Кино\n" - " prompt: |\n" - " You are a DP.\n" - " - id: neutral\n" - " title: Нейтральный\n" - " prompt: Calm.\n", - encoding="utf-8", + root = tmp_path / "assistent-personas" + (root / "_base").mkdir(parents=True) + (root / "personas" / "cinema").mkdir(parents=True) + (root / "_base" / "assistant.json").write_text( + json.dumps({"default_persona": "cinema"}), encoding="utf-8" ) + (root / "personas" / "cinema" / "persona.json").write_text( + json.dumps({"title": "Кино", "accent": "#7aa2f7"}), encoding="utf-8" + ) + (root / "personas" / "cinema" / "bio.json").write_text( + json.dumps({"role": "DP"}), encoding="utf-8" + ) + (root / "personas" / "cinema" / "humor.json").write_text( + json.dumps({"frequency": "rare"}), encoding="utf-8" + ) + puts: dict[str, str] = {} ssh_cmds: list[str] = [] @@ -47,28 +48,67 @@ def test_seed_assistent_personas_writes_overlay(tmp_path: Path, monkeypatch): monkeypatch.setattr("gpu_rent.provision.put_text", fake_put) monkeypatch.setattr("gpu_rent.provision.run_ssh", fake_ssh) + monkeypatch.setattr("gpu_rent.paths.assistent_personas_dir", lambda: root) monkeypatch.setattr( - "gpu_rent.paths.assistent_personas_manifest_path", lambda: yaml_path + "gpu_rent.paths.assistent_personas_example_dir", lambda: tmp_path / "missing" ) monkeypatch.setattr( - "gpu_rent.paths.assistent_personas_example_path", lambda: tmp_path / "missing" + "gpu_rent.paths.assistent_personas_manifest_path", lambda: tmp_path / "no.yaml" ) cfg = MagicMock() - cfg.assistent_personas_manifest = str(yaml_path) + cfg.assistent_personas_dir = str(root) logs: list[str] = [] seed_assistent_personas(cfg, "1.2.3.4", logs.append) - assert any("personas/cinema/persona.json" in p for p in puts) - assert any("personas/cinema/extra.md" in p for p in puts) + assert any(p.endswith("personas/cinema/persona.json") for p in puts) + assert any(p.endswith("personas/cinema/bio.json") for p in puts) + assert any(p.endswith("personas/cinema/humor.json") for p in puts) + assert not any(p.endswith("extra.md") for p in puts) assert "/mnt/swarm_data/Assistent/_base/assistant.json" in puts asst = json.loads(puts["/mnt/swarm_data/Assistent/_base/assistant.json"]) assert asst["default_persona"] == "cinema" assert asst["num_ctx"] == 16384 assert any("rm -f" in c and "personas.json" in c for c in ssh_cmds) - assert not any(p.endswith("personas.yaml") for p in puts) - assert not any(p.endswith("personas.json") for p in puts) - assert any("overlay personas/2" in m for m in logs) + assert any("overlay personas/1" in m for m in logs) + + +def test_seed_skips_exact_if_remote_exists(tmp_path: Path, monkeypatch): + root = tmp_path / "assistent-personas" + (root / "personas" / "leonid").mkdir(parents=True) + (root / "personas" / "leonid" / "persona.json").write_text( + '{"title":"L"}', encoding="utf-8" + ) + (root / "personas" / "leonid" / "exact.json").write_text( + '{"controls":{"preference_bias":0.9}}', encoding="utf-8" + ) + puts: dict[str, str] = {} + + def fake_put(cfg, host, remote, text, mode=0o644): + puts[remote] = text + + def fake_ssh(cfg, host, cmd, check=False, timeout=60): + if "exact.json" in cmd and "test -f" in cmd: + return "yes" + if ".gpu-rent-gpu.json" in cmd: + return "{}" + return "" + + monkeypatch.setattr("gpu_rent.provision.put_text", fake_put) + monkeypatch.setattr("gpu_rent.provision.run_ssh", fake_ssh) + monkeypatch.setattr("gpu_rent.paths.assistent_personas_dir", lambda: root) + monkeypatch.setattr( + "gpu_rent.paths.assistent_personas_example_dir", lambda: tmp_path / "missing" + ) + monkeypatch.setattr( + "gpu_rent.paths.assistent_personas_manifest_path", lambda: tmp_path / "no.yaml" + ) + + cfg = MagicMock() + cfg.assistent_personas_dir = str(root) + seed_assistent_personas(cfg, "h", lambda *_: None) + assert not any(p.endswith("exact.json") for p in puts) + assert any(p.endswith("persona.json") for p in puts) def test_count_wanted_models(monkeypatch):