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 <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-22 01:48:01 +03:00
co-authored by Cursor
parent 03ba4cb6ed
commit 7be2c27a67
13 changed files with 187 additions and 129 deletions
+2
View File
@@ -6,6 +6,8 @@ gpu-rent.vars
ollama-models.yaml ollama-models.yaml
models.yaml models.yaml
assistent-personas.yaml assistent-personas.yaml
assistent-personas/
!assistent-personas.example/
extensions.yaml extensions.yaml
# SwarmUI checkpoints / LoRA dropped into the app tree # SwarmUI checkpoints / LoRA dropped into the app tree
-46
View File
@@ -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/<id>/{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 12 short sentences, then the JSON patch. No lectures, no filler.
Prefer decisive patches. Never invent LoRA names or triggers.
+13
View File
@@ -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/<id>/` are pushed to
`/mnt/swarm_data/Assistent/personas/<id>/`. 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/<id>/*.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.
@@ -0,0 +1,3 @@
{
"default_persona": "neutral"
}
+1 -1
View File
@@ -75,7 +75,7 @@ gpu-rent up --yes --ollama
| `gpu-rent ssh` | Оболочка на VM | | `gpu-rent ssh` | Оболочка на VM |
| `gpu-rent seed-models` | Докачать новые строки Civitai-манифеста на живой диск | | `gpu-rent seed-models` | Докачать новые строки Civitai-манифеста на живой диск |
| `gpu-rent seed-extensions` | Доклонировать/обновить git-репы; restart swarmui | | `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 push` / `push-models` | Локальные деревья → VM |
| `gpu-rent pull-output` | VM `Output/``./Output` | | `gpu-rent pull-output` | VM `Output/``./Output` |
| `gpu-rent capture` / `capture all` | Инвентарь VM → merge **ссылок** в `models.yaml` + `extensions.yaml` (веса не качать) | | `gpu-rent capture` / `capture all` | Инвентарь VM → merge **ссылок** в `models.yaml` + `extensions.yaml` (веса не качать) |
+1 -1
View File
@@ -10,7 +10,7 @@
| Civitai хост | Дефолт API `civitai.red` (полный каталог). `.com` — SFW-витрина, NSFW с неё часто 404. Ссылки `.com`/`.red`/`.green` в манифесте принимаем. 404 → один retry на второй хост. Один токен на оба домена | | 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` автоматом не пишем | | Пул 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` автоматом не пишем |
| Манифест моделей | `<repo>/models.yaml`, типы: checkpoint / lora / vae / embedding / controlnet / upscaler. В git только `models.example.yaml` | | Манифест моделей | `<repo>/models.yaml`, типы: checkpoint / lora / vae / embedding / controlnet / upscaler. В git только `models.example.yaml` |
| Расширения | `<repo>/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/<id>/` на `up` / `seed-personas` | | Расширения | `<repo>/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/<id>/` на `up` / `seed-personas` (не сносит UI-клоны) |
| Autocomplete | До первого старта: скачать word-list в `Data/Autocompletions`, прописать `DefaultUser.AutoComplete.Source`. На каждом `up` сверить GitHub blob sha и обновить файл, если изменился. Дефолт: `tags/danbooru.csv` из a1111-sd-webui-tagcomplete (как в доке SwarmUI) | | Autocomplete | До первого старта: скачать word-list в `Data/Autocompletions`, прописать `DefaultUser.AutoComplete.Source`. На каждом `up` сверить GitHub blob sha и обновить файл, если изменился. Дефолт: `tags/danbooru.csv` из a1111-sd-webui-tagcomplete (как в доке SwarmUI) |
| Доступ | Браузер на туннеле; MCP переключается на облако, пока оно живо; HTTP API SwarmUI через тот же туннель | | Доступ | Браузер на туннеле; MCP переключается на облако, пока оно живо; HTTP API SwarmUI через тот же туннель |
| Preemptible | По умолчанию всегда. Обычный сервер — только `--no-spot` | | Preemptible | По умолчанию всегда. Обычный сервер — только `--no-spot` |
+1 -1
View File
@@ -53,7 +53,7 @@ comfy:
| `ollama` | только при `LLM_RUNTIME=ollama` | | `ollama` | только при `LLM_RUNTIME=ollama` |
| `any-llm` | при `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/<id>/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/<id>/*.json`, плюс `_base/assistant.json` (`default_persona`, опционально `num_ctx` с GPU tier). Seed **не** удаляет overlay-личности, созданные в UI. Legacy `personas.json` / yaml-prompt больше не пишутся.
В git репозитория gpu-rent не коммитить рабочий список с лишними приватными URL сверх примера. В git репозитория gpu-rent не коммитить рабочий список с лишними приватными URL сверх примера.
+1 -1
View File
@@ -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`. \*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/<id>/` (+ `_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/<id>/` (+ `_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: `ollama-models.yaml` entries:
+1 -1
View File
@@ -944,7 +944,7 @@ def seed_extensions_cmd() -> None:
@app.command("seed-personas") @app.command("seed-personas")
def seed_personas_cmd() -> None: def seed_personas_cmd() -> None:
"""Push assistent-personas.yaml → VM Assistent overlay (без полного up).""" """Push assistent-personas/ → VM Assistent overlay (без полного up)."""
try: try:
from gpu_rent.provision import seed_assistent_personas from gpu_rent.provision import seed_assistent_personas
+11
View File
@@ -48,11 +48,22 @@ def models_manifest_path() -> Path:
return app_root() / "models.yaml" 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: def assistent_personas_manifest_path() -> Path:
"""Deprecated yaml path — kept for migration warning only."""
return app_root() / "assistent-personas.yaml" return app_root() / "assistent-personas.yaml"
def assistent_personas_example_path() -> Path: def assistent_personas_example_path() -> Path:
"""Deprecated yaml example — prefer assistent_personas_example_dir()."""
return app_root() / "assistent-personas.example.yaml" return app_root() / "assistent-personas.example.yaml"
+74 -50
View File
@@ -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: def seed_assistent_personas(cfg: Config, host: str, log: Log) -> None:
"""Push local assistent-personas.yaml → VM Assistent/personas/<id>/ overlay. """Push local assistent-personas/ folder → VM Assistent/ overlay.
Laptop yaml stays the editor; on VM we write persona.json + extra.md and Copies personas/<id>/*.json (+ optional extra.md) and sparse _base/assistant.json
overlay assistant.json (default_persona, optional num_ctx). Does not clobber (default_persona + optional num_ctx). Does not delete overlay personas missing
voice/likes/dislikes/rules already on disk. No longer writes legacy from the laptop (VM-created clones stay). Does not clobber exact.controls.
personas.json / personas.yaml dumps. 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( 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(): if not local.is_dir():
example = assistent_personas_example_path() example = assistent_personas_example_dir()
if example.is_file(): if example.is_dir():
local = example local = example
else: 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 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" 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" default_id = "neutral"
personas = data.get("personas") or [] 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 written = 0
if isinstance(personas, list): if personas_root.is_dir():
for row in personas: for pdir in sorted(personas_root.iterdir()):
if not isinstance(row, dict): if not pdir.is_dir():
continue continue
pid = _safe_persona_id(row.get("id")) pid = _safe_persona_id(pdir.name)
if not pid: if not pid:
continue continue
title = str(row.get("title") or pid).strip() or pid remote_p = f"{remote_dir}/personas/{pid}"
prompt = str(row.get("prompt") or "").strip() run_ssh(cfg, host, f"mkdir -p {shlex.quote(remote_p)}", check=False)
pdir = f"{remote_dir}/personas/{pid}" for f in sorted(pdir.iterdir()):
run_ssh(cfg, host, f"mkdir -p {shlex.quote(pdir)}", check=False) if not f.is_file():
meta = {"title": title, "tagline": title, "accent": "#8b949e"} continue
put_text( name = f.name
cfg, if name.endswith(".json") or name == "extra.md":
host, if ".." in name or "/" in name or "\\" in name:
f"{pdir}/persona.json", continue
json.dumps(meta, ensure_ascii=False, indent=2) + "\n", # Never overwrite exact.json controls from laptop if remote has
) # user-tuned values — merge only when seeding a missing exact,
if prompt: # or push non-controls shelves always. For simplicity: push all
put_text( # shelves except skip exact.json when remote already has it
cfg, # (preserves preference_bias). First-time still seeds exact.
host, remote_f = f"{remote_p}/{name}"
f"{pdir}/extra.md", if name == "exact.json":
prompt if prompt.endswith("\n") else prompt + "\n", 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 written += 1
assistant_overlay: dict = {"default_persona": default_id} assistant_overlay: dict = {"default_persona": default_id}
+17 -6
View File
@@ -16,8 +16,8 @@ from gpu_rent.llm_runtime import (
) )
from gpu_rent.paths import ( from gpu_rent.paths import (
app_root, app_root,
assistent_personas_example_path, assistent_personas_dir,
assistent_personas_manifest_path, assistent_personas_example_dir,
env_path, env_path,
extensions_manifest_path, extensions_manifest_path,
models_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}") 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( def run_setup(
*, *,
llm: str | None = None, 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 / "env.example", env_path(), ".env", log)
_copy_if_missing(root / "models.example.yaml", models_manifest_path(), "models.yaml", log) _copy_if_missing(root / "models.example.yaml", models_manifest_path(), "models.yaml", log)
_copy_if_missing( _copy_tree_if_missing(
assistent_personas_example_path(), assistent_personas_example_dir(),
assistent_personas_manifest_path(), assistent_personas_dir(),
"assistent-personas.yaml", "assistent-personas/",
log, log,
) )
_copy_if_missing( _copy_if_missing(
+62 -22
View File
@@ -6,8 +6,6 @@ import json
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest
from gpu_rent.provision import _safe_persona_id, count_wanted_models_on_vm, seed_assistent_personas 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): def test_seed_assistent_personas_writes_overlay(tmp_path: Path, monkeypatch):
yaml_path = tmp_path / "assistent-personas.yaml" root = tmp_path / "assistent-personas"
yaml_path.write_text( (root / "_base").mkdir(parents=True)
"default: cinema\n" (root / "personas" / "cinema").mkdir(parents=True)
"personas:\n" (root / "_base" / "assistant.json").write_text(
" - id: cinema\n" json.dumps({"default_persona": "cinema"}), encoding="utf-8"
" title: Кино\n"
" prompt: |\n"
" You are a DP.\n"
" - id: neutral\n"
" title: Нейтральный\n"
" prompt: Calm.\n",
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] = {} puts: dict[str, str] = {}
ssh_cmds: list[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.put_text", fake_put)
monkeypatch.setattr("gpu_rent.provision.run_ssh", fake_ssh) monkeypatch.setattr("gpu_rent.provision.run_ssh", fake_ssh)
monkeypatch.setattr("gpu_rent.paths.assistent_personas_dir", lambda: root)
monkeypatch.setattr( 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( 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 = MagicMock()
cfg.assistent_personas_manifest = str(yaml_path) cfg.assistent_personas_dir = str(root)
logs: list[str] = [] logs: list[str] = []
seed_assistent_personas(cfg, "1.2.3.4", logs.append) seed_assistent_personas(cfg, "1.2.3.4", logs.append)
assert any("personas/cinema/persona.json" in p for p in puts) assert any(p.endswith("personas/cinema/persona.json") for p in puts)
assert any("personas/cinema/extra.md" in p 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 assert "/mnt/swarm_data/Assistent/_base/assistant.json" in puts
asst = json.loads(puts["/mnt/swarm_data/Assistent/_base/assistant.json"]) asst = json.loads(puts["/mnt/swarm_data/Assistent/_base/assistant.json"])
assert asst["default_persona"] == "cinema" assert asst["default_persona"] == "cinema"
assert asst["num_ctx"] == 16384 assert asst["num_ctx"] == 16384
assert any("rm -f" in c and "personas.json" in c for c in ssh_cmds) 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 any("overlay personas/1" in m for m in logs)
assert not any(p.endswith("personas.json") for p in puts)
assert any("overlay personas/2" 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): def test_count_wanted_models(monkeypatch):