Add Assistent reference books and remove training dataset output.

Introduce books/ with civitai-krea2 and HF fictext builders, sha-diff seed to VM, and drop train.jsonl from the civitai scrape pipeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-23 22:56:07 +03:00
co-authored by Cursor
parent 84ed0bb47a
commit 07bb521b70
27 changed files with 836 additions and 64 deletions
+458
View File
@@ -0,0 +1,458 @@
"""Assistent reference books: build locally, seed to VM on change (FTS search.jsonl)."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from collections.abc import Callable, Iterable
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import httpx
import yaml
from gpu_rent.civitai_dataset import looks_minor, read_jsonl, write_jsonl
from gpu_rent.config import load_config
from gpu_rent.errors import GpuRentError
from gpu_rent.paths import app_root
Log = Callable[[str], None]
BOOK_CIVITAI = "civitai-krea2"
BOOK_FICTEXT_RPLUS = "ru-fictext-rplus"
BOOK_FICTEXT_FULL = "ru-fictext-full"
VM_BOOKS_PREFIX = "/mnt/swarm_data/Assistent/books"
SIDEcar_NAME = ".gpu-rent-meta.json"
HF_REPO = "krplt/ru-fictext-nsfw"
HF_FILES = {
BOOK_FICTEXT_RPLUS: "ru-fictext-nsfw-data-r+.parquet",
BOOK_FICTEXT_FULL: "ru-fictext-nsfw-data.parquet",
}
CHUNK_CHARS = 500
CHUNK_OVERLAP = 80
EXCERPT_MAX = 500
BOOK_SPECS: dict[str, dict[str, Any]] = {
BOOK_CIVITAI: {
"kind": "book",
"id": BOOK_CIVITAI,
"title": "Civitai Krea2",
"description": (
"Примеры промптов, негативов, steps/cfg и LoRA для Krea 2 с Civitai. "
"Ищи сюда, когда нужен рабочий prompt или параметры генерации."
),
"content_kind": "prompt-examples",
"language": "en",
"tags": ["civitai", "krea2", "prompts"],
"source": "civitai-dataset",
"license": "civitai-terms",
},
BOOK_FICTEXT_RPLUS: {
"kind": "book",
"id": BOOK_FICTEXT_RPLUS,
"title": "RU ficbook R+",
"description": (
"Отрывки русской фанфикшн (mature/NSFW) для стиля, лексики и тона сцен. "
"Не цитируй дословно длинно — бери формулировки и ритм."
),
"content_kind": "prose-style",
"language": "ru",
"tags": ["fanfiction", "nsfw", "russian"],
"source": f"huggingface.co/datasets/{HF_REPO}",
"license": "cc-by-4.0",
},
BOOK_FICTEXT_FULL: {
"kind": "book",
"id": BOOK_FICTEXT_FULL,
"title": "RU ficbook (full)",
"description": (
"Отрывки русской фанфикшн (NSFW + safe) для стиля и лексики. "
"Шире чем R+; для нейтральных сцен предпочитай safe-теги."
),
"content_kind": "prose-style",
"language": "ru",
"tags": ["fanfiction", "russian"],
"source": f"huggingface.co/datasets/{HF_REPO}",
"license": "cc-by-4.0",
},
}
def _log(msg: str) -> None:
print(msg, flush=True)
def books_root(root: Path | None = None) -> Path:
return (root or app_root()) / "books"
def book_dir(book_id: str, root: Path | None = None) -> Path:
return books_root(root) / book_id
def write_book_yaml(book_id: str, root: Path | None = None) -> Path:
spec = BOOK_SPECS.get(book_id)
if not spec:
raise GpuRentError(f"Unknown book id: {book_id}")
path = book_dir(book_id, root) / "book.yaml"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml.safe_dump(spec, allow_unicode=True, sort_keys=False), encoding="utf-8")
return path
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def write_meta(
book_id: str,
*,
row_count: int,
root: Path | None = None,
source_sha: str | None = None,
extra: dict[str, Any] | None = None,
) -> Path:
bdir = book_dir(book_id, root)
search = bdir / "search.jsonl"
if not search.is_file():
raise GpuRentError(f"Нет {search} для meta")
payload: dict[str, Any] = {
"book_id": book_id,
"row_count": row_count,
"content_sha": sha256_file(search),
"built_at": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"search_bytes": search.stat().st_size,
}
if source_sha:
payload["source_sha"] = source_sha
if extra:
payload.update(extra)
path = bdir / "meta.json"
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return path
def read_meta(book_id: str, root: Path | None = None) -> dict[str, Any] | None:
path = book_dir(book_id, root) / "meta.json"
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return None
return data if isinstance(data, dict) else None
def iter_installed_books(root: Path | None = None) -> list[str]:
root_p = books_root(root)
if not root_p.is_dir():
return []
out: list[str] = []
for child in sorted(root_p.iterdir()):
if not child.is_dir():
continue
if (child / "search.jsonl").is_file():
out.append(child.name)
return out
def civitai_search_to_book_row(row: dict[str, Any], book_id: str = BOOK_CIVITAI) -> dict[str, Any]:
prompt = str(row.get("prompt") or "").strip()
neg = str(row.get("negative") or row.get("negativePrompt") or "").strip()
parts = [prompt] if prompt else []
if neg:
parts.append(f"Negative: {neg}")
params = row.get("params") if isinstance(row.get("params"), dict) else {}
for key, label in (("steps", "steps"), ("cfg", "cfg"), ("cfgScale", "cfg"), ("sampler", "sampler")):
if params.get(key) is not None:
parts.append(f"{label}: {params[key]}")
text = "\n".join(parts)[:EXCERPT_MAX]
meta = {
"prompt": prompt,
"negative": neg,
"params": params,
"loras": list(row.get("loras") or []),
"modelVersionId": row.get("modelVersionId"),
"kind": row.get("kind"),
"score": row.get("score"),
}
rid = row.get("id")
return {
"id": f"{book_id}:{rid}",
"book": book_id,
"title": "",
"tags": list(row.get("tags") or []),
"text": text,
"body": prompt[:2000] if prompt else text,
"rating": row.get("rating") or "pg",
"meta": meta,
}
def _chunk_text(text: str, *, chunk_chars: int = CHUNK_CHARS, overlap: int = CHUNK_OVERLAP) -> list[str]:
text = re.sub(r"\r\n?", "\n", text.strip())
if not text:
return []
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
chunks: list[str] = []
for para in paras:
if len(para) <= chunk_chars:
chunks.append(para)
continue
start = 0
while start < len(para):
end = min(len(para), start + chunk_chars)
chunks.append(para[start:end])
if end >= len(para):
break
start = max(0, end - overlap)
return chunks
def fictext_row_to_book_rows(
row: dict[str, Any],
book_id: str,
*,
row_index: int,
) -> list[dict[str, Any]]:
title = str(row.get("title") or "").strip()
tags_raw = row.get("tags")
tags: list[str] = []
if isinstance(tags_raw, list):
tags = [str(t).strip() for t in tags_raw if str(t).strip()]
elif isinstance(tags_raw, str):
tags = [t.strip() for t in re.split(r"[,;]", tags_raw) if t.strip()]
text = str(row.get("text") or "").strip()
if not text or looks_minor(text, tags):
return []
out: list[dict[str, Any]] = []
for ci, chunk in enumerate(_chunk_text(text)):
excerpt = chunk[:EXCERPT_MAX]
out.append(
{
"id": f"{book_id}:{row_index}:{ci}",
"book": book_id,
"title": title,
"tags": tags[:20],
"text": excerpt,
"body": chunk[:2000],
"rating": "r" if book_id == BOOK_FICTEXT_RPLUS else "pg13",
"meta": {"source_row": row_index, "chunk": ci},
}
)
return out
def build_civitai_book(*, out_root: Path | None = None, log: Log = _log) -> dict[str, int]:
"""Write books/civitai-krea2 from datasets/civitai/search.jsonl."""
root = out_root or app_root()
src = root / "datasets" / "civitai" / "search.jsonl"
if not src.is_file():
# fallback: build from catalog images via civitai_dataset split output path
raise GpuRentError(f"Нет {src} — сначала civitai-dataset split")
rows = read_jsonl(src)
book_rows = [civitai_search_to_book_row(r) for r in rows]
bdir = book_dir(BOOK_CIVITAI, root)
write_book_yaml(BOOK_CIVITAI, root)
n = write_jsonl(bdir / "search.jsonl", book_rows)
write_meta(BOOK_CIVITAI, row_count=n, root=root, extra={"source": str(src)})
log(f"books/{BOOK_CIVITAI}: {n} rows → {bdir}")
return {"rows": n}
def _hf_download_url(filename: str) -> str:
return f"https://huggingface.co/datasets/{HF_REPO}/resolve/main/{filename}"
def _require_pyarrow():
try:
import pyarrow.parquet as pq # noqa: F401
return pq
except ImportError as exc:
raise GpuRentError(
"Нужен pyarrow для fictext: pip install 'gpu-rent[books]' или pip install pyarrow"
) from exc
def download_fictext_parquet(
book_id: str,
*,
out_root: Path | None = None,
log: Log = _log,
token: str | None = None,
) -> Path:
if book_id not in HF_FILES:
raise GpuRentError(f"Unknown fictext book: {book_id}")
filename = HF_FILES[book_id]
bdir = book_dir(book_id, out_root)
bdir.mkdir(parents=True, exist_ok=True)
dest = bdir / filename
url = _hf_download_url(filename)
headers: dict[str, str] = {}
if token:
headers["Authorization"] = f"Bearer {token}"
log(f"download: {url}")
with httpx.Client(follow_redirects=True, timeout=600.0) as client:
with client.stream("GET", url, headers=headers) as resp:
resp.raise_for_status()
with dest.open("wb") as fh:
for chunk in resp.iter_bytes(1024 * 1024):
fh.write(chunk)
log(f"saved {dest} ({dest.stat().st_size // 1024} KB)")
return dest
def build_fictext_book(
book_id: str,
*,
out_root: Path | None = None,
log: Log = _log,
max_rows: int | None = None,
) -> dict[str, int]:
if book_id not in HF_FILES:
raise GpuRentError(f"Unknown fictext book: {book_id}")
pq = _require_pyarrow()
bdir = book_dir(book_id, out_root)
parquet = bdir / HF_FILES[book_id]
if not parquet.is_file():
raise GpuRentError(f"Нет {parquet} — сначала books download fictext")
write_book_yaml(book_id, out_root)
table = pq.read_table(parquet)
data = table.to_pydict()
titles = data.get("title") or []
tags_col = data.get("tags") or []
texts = data.get("text") or []
n_src = len(texts)
book_rows: list[dict] = []
for i in range(n_src):
if max_rows is not None and i >= max_rows:
break
row = {
"title": titles[i] if i < len(titles) else "",
"tags": tags_col[i] if i < len(tags_col) else [],
"text": texts[i] if i < len(texts) else "",
}
book_rows.extend(fictext_row_to_book_rows(row, book_id, row_index=i))
n = write_jsonl(bdir / "search.jsonl", book_rows)
write_meta(
book_id,
row_count=n,
root=out_root,
source_sha=sha256_file(parquet),
extra={"source_parquet": parquet.name, "source_stories": n_src},
)
log(f"books/{book_id}: {n} chunks from {n_src} stories → {bdir}")
return {"rows": n, "stories": n_src}
def cmd_download_fictext(
*,
variant: str = "both",
out_root: Path | None = None,
log: Log = _log,
) -> None:
cfg = load_config()
token = (getattr(cfg, "hf_token", None) or "").strip() or None
ids = []
if variant in {"rplus", "both"}:
ids.append(BOOK_FICTEXT_RPLUS)
if variant in {"full", "both"}:
ids.append(BOOK_FICTEXT_FULL)
for book_id in ids:
download_fictext_parquet(book_id, out_root=out_root, log=log, token=token)
build_fictext_book(book_id, out_root=out_root, log=log)
def remote_book_dir(book_id: str) -> str:
return f"{VM_BOOKS_PREFIX}/{book_id}"
def seed_book_files(local_book: Path, remote_book: str) -> list[tuple[Path, str]]:
"""Local book dir files to push."""
pairs: list[tuple[Path, str]] = []
for name in ("book.yaml", "search.jsonl", "meta.json"):
local = local_book / name
if local.is_file():
pairs.append((local, f"{remote_book}/{name}"))
meta = local_book / "meta.json"
if meta.is_file():
pairs.append((meta, f"{remote_book}/{SIDEcar_NAME}"))
return pairs
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="python -m gpu_rent.books",
description="Build Assistent reference books (search.jsonl + book.yaml)",
)
p.add_argument("--out", type=Path, default=None, help="App root")
sub = p.add_subparsers(dest="cmd")
d = sub.add_parser("download", help="Download HF fictext parquet(s)")
d.add_argument(
"target",
choices=["fictext"],
nargs="?",
default="fictext",
)
d.add_argument("--variant", choices=["rplus", "full", "both"], default="both")
b = sub.add_parser("build", help="Build book search.jsonl")
b.add_argument("target", choices=["civitai", "fictext", "all"])
b.add_argument("--variant", choices=["rplus", "full", "both"], default="both")
sub.add_parser("list", help="List local books with meta")
return p
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
parser = build_parser()
if not argv:
argv = ["list"]
args = parser.parse_args(argv)
out = args.out
try:
if args.cmd == "download":
if args.target == "fictext":
cmd_download_fictext(variant=args.variant, out_root=out)
elif args.cmd == "build":
if args.target in {"civitai", "all"}:
build_civitai_book(out_root=out)
if args.target in {"fictext", "all"}:
for book_id in (
[BOOK_FICTEXT_RPLUS, BOOK_FICTEXT_FULL]
if args.variant == "both"
else [BOOK_FICTEXT_RPLUS if args.variant == "rplus" else BOOK_FICTEXT_FULL]
):
build_fictext_book(book_id, out_root=out)
elif args.cmd == "list":
for bid in iter_installed_books(out):
meta = read_meta(bid, out) or {}
log(
f"{bid}: rows={meta.get('row_count', '?')} "
f"sha={str(meta.get('content_sha', ''))[:12]}"
)
else:
parser.print_help()
return 0
except GpuRentError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+10 -28
View File
@@ -1,4 +1,4 @@
"""Local Civitai Krea2 gallery scrape → train.jsonl + search.jsonl (no image files)."""
"""Local Civitai Krea2 gallery scrape → search.jsonl + Assistent book (no image files)."""
from __future__ import annotations
@@ -522,28 +522,6 @@ def cmd_scrape(
return have
def train_row(row: dict[str, Any]) -> dict[str, Any]:
tags = row.get("tags") or []
tag_s = ", ".join(str(t) for t in tags[:12])
rating = row.get("rating") or "pg"
instruction = f"Write a Krea 2 prompt.\nTags: {tag_s}\nRating: {rating}"
params = row.get("params") if isinstance(row.get("params"), dict) else {}
parts = [str(row.get("prompt") or "").strip()]
neg = str(row.get("negativePrompt") or "").strip()
if neg:
parts.append(f"Negative: {neg}")
for key, label in (
("steps", "steps"),
("cfgScale", "cfg"),
("sampler", "sampler"),
("seed", "seed"),
("size", "size"),
):
if params.get(key) is not None:
parts.append(f"{label}: {params[key]}")
return {"instruction": instruction, "output": "\n".join(parts)}
def search_row(row: dict[str, Any]) -> dict[str, Any]:
resources = row.get("resources") if isinstance(row.get("resources"), list) else []
loras: list[dict[str, Any]] = []
@@ -600,14 +578,12 @@ def cmd_split(*, out_root: Path | None = None, log: Log = _log) -> dict[str, int
by_kind: dict[str, list] = {"checkpoint": [], "lora": []}
by_rating: dict[str, list] = {}
train: list[dict] = []
search: list[dict] = []
for row in rows:
kind = str(row.get("kind") or "checkpoint")
by_kind.setdefault(kind, []).append(row)
rating = str(row.get("rating") or "pg")
by_rating.setdefault(rating, []).append(row)
train.append(train_row(row))
search.append(search_row(row))
counts: dict[str, int] = {}
@@ -618,13 +594,19 @@ def cmd_split(*, out_root: Path | None = None, log: Log = _log) -> dict[str, int
safe = re.sub(r"[^a-z0-9]+", "", rating.lower()) or "pg"
path = root / "by_rating" / f"{safe}.jsonl"
counts[f"rating:{safe}"] = write_jsonl(path, items)
counts["train"] = write_jsonl(root / "train.jsonl", train)
counts["search"] = write_jsonl(root / "search.jsonl", search)
log(
"split: "
+ ", ".join(f"{k}={v}" for k, v in sorted(counts.items()))
+ f"{root}"
)
try:
from gpu_rent.books import build_civitai_book
book_counts = build_civitai_book(out_root=out_root or app_root(), log=log)
counts["book:civitai-krea2"] = book_counts.get("rows", 0)
except GpuRentError as exc:
log(f"book build warn: {exc}")
return counts
@@ -657,7 +639,7 @@ def cmd_all(
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="python -m gpu_rent.civitai_dataset",
description="Scrape Civitai Krea2 galleries → train.jsonl + search.jsonl",
description="Scrape Civitai Krea2 galleries → search.jsonl + books/civitai-krea2",
)
p.add_argument(
"--out",
@@ -676,7 +658,7 @@ def build_parser() -> argparse.ArgumentParser:
s.add_argument("--per-version", type=int, default=DEFAULT_PER_VERSION)
s.add_argument("--min-score", type=int, default=DEFAULT_MIN_SCORE)
sub.add_parser("split", help="Write by_kind / by_rating / train / search")
sub.add_parser("split", help="Write by_kind / by_rating / search + civitai book")
a = sub.add_parser("all", help="discover + scrape + split (default)")
a.add_argument("--top-checkpoints", type=int, default=DEFAULT_TOP_CHECKPOINTS)
+14
View File
@@ -1007,6 +1007,20 @@ def seed_extensions_cmd() -> None:
_die(exc)
@app.command("seed-books")
def seed_books_cmd(
force: bool = typer.Option(False, "--force", help="Push all books even if sha unchanged"),
) -> None:
"""Push books/*/ → VM Assistent/books/ (changed only unless --force)."""
try:
from gpu_rent.provision import seed_books
cfg, host = _live()
seed_books(cfg, host, log, force=force)
except GpuRentError as exc:
_die(exc)
@app.command("seed-personas")
def seed_personas_cmd() -> None:
"""Push assistent-extensions/ → VM Assistent/extensions (+ _base)."""
+10
View File
@@ -525,6 +525,16 @@ def build_chat_trace(
}
if isinstance(response, dict) and response.get("civitai_results"):
out["civitai_results"] = response.get("civitai_results")
if isinstance(response, dict) and isinstance(response.get("knowledge"), dict):
out["knowledge"] = response.get("knowledge")
elif isinstance(response, dict) and response.get("civitai_results"):
# Shim until swarm-assistent ships unified knowledge block
out["knowledge"] = {
"catalog": [],
"hops": [],
"results": response.get("civitai_results"),
"legacy": "civitai_results",
}
return out
+8
View File
@@ -72,6 +72,14 @@ def assistent_personas_manifest_path() -> Path:
return app_root() / "assistent-personas.yaml"
def books_dir() -> Path:
return app_root() / "books"
def books_example_dir() -> Path:
return app_root() / "books.example"
def assistent_personas_example_path() -> Path:
"""Deprecated yaml example — prefer assistent_extensions_example_dir()."""
return app_root() / "assistent-personas.example.yaml"
+72 -2
View File
@@ -1141,11 +1141,81 @@ def seed_assistent_personas(cfg: Config, host: str, log: Log) -> None:
f"assistent-extensions → packs/{pushed} "
f"(default={default_id}{ctx_note})"
)
seed_civitai_examples(cfg, host, log)
seed_books(cfg, host, log)
def seed_books(cfg: Config, host: str, log: Log, *, force: bool = False) -> None:
"""Push changed books/*/ → Assistent/books/ (content_sha diff)."""
import json
from gpu_rent.books import (
SIDEcar_NAME,
VM_BOOKS_PREFIX,
iter_installed_books,
read_meta,
remote_book_dir,
seed_book_files,
)
from gpu_rent.paths import app_root
from gpu_rent.ssh_ops import put_file, put_text
root = Path(getattr(cfg, "app_root", None) or app_root())
book_ids = iter_installed_books(root)
if not book_ids:
seed_civitai_examples(cfg, host, log)
return
run_ssh(cfg, host, f"mkdir -p {shlex.quote(VM_BOOKS_PREFIX)}", check=False)
pushed = 0
for book_id in book_ids:
meta = read_meta(book_id, root)
local_sha = str((meta or {}).get("content_sha") or "").strip()
if not local_sha:
log(f"books/{book_id}: нет meta.content_sha — skip")
continue
remote_dir = remote_book_dir(book_id)
sidecar_remote = f"{remote_dir}/{SIDEcar_NAME}"
remote_raw = run_ssh(
cfg,
host,
f"test -f {shlex.quote(sidecar_remote)} && cat {shlex.quote(sidecar_remote)} || true",
check=False,
timeout=20,
).strip()
remote_sha = ""
if remote_raw:
try:
remote_meta = json.loads(remote_raw)
if isinstance(remote_meta, dict):
remote_sha = str(remote_meta.get("content_sha") or "")
except json.JSONDecodeError:
remote_sha = ""
if not force and remote_sha == local_sha:
log(f"books/{book_id}: unchanged")
continue
run_ssh(cfg, host, f"mkdir -p {shlex.quote(remote_dir)}", check=False)
local_dir = root / "books" / book_id
for local_path, remote_path in seed_book_files(local_dir, remote_dir):
put_file(cfg, host, local_path, remote_path)
sidecar_payload = json.dumps(
{"content_sha": local_sha, "book_id": book_id, **(meta or {})},
ensure_ascii=False,
)
put_text(cfg, host, sidecar_remote, sidecar_payload + "\n")
size_kb = max(1, (local_dir / "search.jsonl").stat().st_size // 1024)
log(f"books/{book_id} -> {remote_dir} ({size_kb} KB, sha={local_sha[:12]})")
pushed += 1
if pushed:
log(f"books: pushed {pushed}/{len(book_ids)}")
elif book_ids:
log(f"books: all {len(book_ids)} unchanged")
def seed_civitai_examples(cfg: Config, host: str, log: Log) -> None:
"""Push datasets/civitai/search.jsonl → Assistent/civitai-examples.jsonl (FTS, no embed)."""
"""Legacy FTS path when books/ not built yet."""
from gpu_rent.paths import app_root
from gpu_rent.ssh_ops import put_file