|
|
||
|---|---|---|
| .gitignore | ||
| config.example.json | ||
| debug_writer.py | ||
| distill.py | ||
| export_sft.py | ||
| gabgod | ||
| personas.json | ||
| README.md | ||
| schema.sql | ||
NPCvector
Turn any scraped text corpus + pgvector into a cast of game NPCs with authentic voices — without ever shipping a word of the original corpus.
First deployment: GABGOD on ultra — Soulstrut (818k embedded posts) + VeryGoodPlus (59k) forum corpus → sarcastic shopkeepers for PROCITY's 90s Australian shopping town. But the pattern is generic: any corpus you can embed, any cast of characters, any game.
THE PATTERN
┌─────────────────────────────────────────────────────────────────┐
│ corpus (pgvector) personas.json │
│ forum posts, reviews, who talks, how they talk, │
│ transcripts, chat logs what situations they're in │
│ │ │ │
│ └───────► distill.py ◄─────┘ │
│ 1. embed the situation (nomic-embed-text, 768d) │
│ 2. pgvector top-k similar corpus posts = STYLE REFERENCE │
│ 3. writer LLM (toggleable, see below) writes ORIGINAL lines │
│ │ │
│ ▼ │
│ line_bank table (approved = NULL until a human reviews) │
│ │ │
│ ├──► in-game retrieval dialogue (line bank + small │
│ │ embedding model in the browser via LiteRT.js — │
│ │ player types anything, semantic match picks the │
│ │ NPC's comeback; tiny download; fully offline) │
│ │ │
│ └──► export_sft.py → SFT JSONL → LoRA a local model │
│ (bake the VOICE into a model; facts stay in RAG) │
└─────────────────────────────────────────────────────────────────┘
Why distill instead of RAG-ing the corpus straight into the game?
- Real people wrote the corpus. Verbatim forum posts coming out of an NPC's
mouth in a public game is a legal grey zone and recognizably someone. The
corpus is used as voice and attitude reference only; the prompt forbids
copying, and provenance (
source_post_ids) is kept so any line can be audited against its references. - Review gate. Every generated line lands with
approved = NULL. A human eyeballs before anything ships (house rule — same as mocapgod QC clips). Essential when the writer model is uncensored (below). - Tiny runtime. A reviewed line bank + a small embedding model runs in the browser (LiteRT.js / WASM / WebGPU) — no server, no 2GB model download for players, works offline on monsterrobot.games.
The abliterated toggle
./gabgod abliterated status # which writer distill.py will use
./gabgod abliterated on # uncensored writer (local, private generation)
./gabgod abliterated off # stock writer (safe default)
config.json → writer.abliterated flips between two OpenAI-compatible endpoints:
| model | where | when | |
|---|---|---|---|
| off | qwen2.5:7b | m4pro Ollama | safe default, boring but obedient |
| on | supergemma4-26b-uncensored (16GB GGUF) | ultra's own Ollama | commits to the bit, no refusals |
(First choice was gemma-4-E4B-it-OBLITERATED — abandoned after it drifted into Chinese mid-run and failed 25/40 topics. Abliteration damages small models; the 26B holds together. See "Earned gotchas" below.)
House rule: abliterated output never ships publicly unreviewed. The toggle
gates generation; the approved column gates shipping. Both must be green
for a line to reach a public game. For private/LAN free-chat NPCs, point the
game at the abliterated endpoint directly and skip the bank — your house, your
rules.
Using it on something else later (the reusable bit)
- Get a corpus into pgvector. Any table with
(id, body_text, embedding vector(768))embedded withnomic-embed-textworks. Add the table name toconfig.json → retrieval.sources. (In discogs_full you already have embedded: soulstrut_post, vgplus_post, ishkur_style, discogs_reviews 414k, yt_transcript, release_wikipedia 1.3M, artist_wikipedia, semantic_gold…) - Write personas.json. Per character:
voice(who they are, how they talk) andtopics(situations they'll need lines for). The topics double as the retrieval queries — write them in the corpus's vocabulary for better matches. - Run
./gabgod distill. Heartbeat in~/.jobs/gabgod-distill.status, resume-safe (persona-topics with enough lines are skipped on rerun). - Review (
./gabgod sample <persona>, then setapprovedin psql/pgweb), export (./gabgod export→sft/train.jsonl+valid.jsonl), and either ship the bank or LoRA the voice (mlx_lm.lora --data sft/ ...on m3ultra).
Ideas already on the list: RECORDGOD store-assistant voice from discogs_reviews; MRPGI narrator personas; toastsim's judge from the same forums; Ishkur-flavoured genre-pedant NPC from ishkur_style (153 embedded style blurbs).
Files
| file | what |
|---|---|
distill.py |
the forge: retrieve → write → insert. --persona X --topics N --dry-run to smoke-test |
personas.json |
the cast (currently: PROCITY's six shopkeepers) |
config.json |
endpoints + toggle + retrieval knobs (gitignored; start from config.example.json) |
schema.sql |
gabgod.line_bank (+ schema) — run once per database |
export_sft.py |
approved lines → chat-format JSONL for LoRA |
gabgod |
CLI: abliterated on/off/status · distill · status · sample · counts · export |
Ops notes
- Deployment lives on ultra
~/Documents/GABGOD(the data's there; scripts use/opt/homebrew/bin/python3per TCC rule). This repo is the canonical source. - Long runs follow the heartbeat convention (
~/.jobs/, overwrite-not-append, die loudly, checkpoint+resume). - Embeddings: nomic-embed-text (768d) — must match whatever embedded the corpus,
or retrieval silently degrades. Check dims with
atttypmodon the vector column. - Postgres gotcha that bit here:
n_live_tupstats on the corpus tables read 0 (stale autovacuum); trustcount(*), notpg_stat_user_tables. - Ollama structured-output note: small models ignore "exactly N lines" — the
parser takes whatever valid JSON array comes back (raw_decode from first
[).
Earned gotchas (one bad evening's tuition)
- Abliterated small models are brain-damaged. The E4B weight-edit produced Chinese, word salad, and malformed JSON under a real workload despite passing a simple smoke test. Test with the PRODUCTION prompt, not "say 3 phrases".
- Reasoning models + long prompts = empty answers. supergemma via the
OpenAI-compat
/v1endpoint burned its entire 4096-token budget on internal drafts (finish_reason: length, 12k chars of reasoning, zero content). Fix: use ollama's native/api/chatwith"think": false— 12/12 lines in 9s vs nothing in 5 minutes. distill.py falls back automatically for models with no think switch. - Keep the reference block small. >6 refs × 400 chars measurably worsens
deliberation spirals; retrieval
top_kcan stay high, but cap what you show the writer. sane_line()is load-bearing. Language-drift and fragment junk WILL reach the DB without the ascii-ratio/length gate; purging after the fact isdelete ... where length(regexp_replace(line,'[[:ascii:]]','','g')) > 3.- Resume counts must be per-writer-model (
writer_model=%sin the skip query), or a failed model's junk "completes" topics and blocks the good model from refilling them.