NPCvector/README.md
type-two 07a0db6b61 NPCvector v1: pgvector corpus → original NPC voice lines (GABGOD)
Distill pattern: embedded forum corpus (style reference only, never shipped
verbatim) → persona line banks with human review gate → SFT export for LoRA.
First deployment: PROCITY shopkeepers from Soulstrut/VGplus on ultra.
Includes the abliterated writer toggle and the earned-gotchas writeup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:19:31 +10:00

138 lines
8.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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?
1. **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.
2. **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).
3. **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
```bash
./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)
1. **Get a corpus into pgvector.** Any table with `(id, body_text, embedding vector(768))`
embedded with `nomic-embed-text` works. Add the table name to
`config.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…)
2. **Write personas.json.** Per character: `voice` (who they are, how they talk)
and `topics` (situations they'll need lines for). The topics double as the
retrieval queries — write them in the corpus's vocabulary for better matches.
3. **Run `./gabgod distill`.** Heartbeat in `~/.jobs/gabgod-distill.status`,
resume-safe (persona-topics with enough lines are skipped on rerun).
4. **Review** (`./gabgod sample <persona>`, then set `approved` in 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/python3` per 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 `atttypmod` on the vector column.
- Postgres gotcha that bit here: `n_live_tup` stats on the corpus tables read 0
(stale autovacuum); trust `count(*)`, not `pg_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)
1. **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".
2. **Reasoning models + long prompts = empty answers.** supergemma via the
OpenAI-compat `/v1` endpoint burned its entire 4096-token budget on internal
drafts (`finish_reason: length`, 12k chars of reasoning, zero content). Fix:
use ollama's native `/api/chat` with `"think": false` — 12/12 lines in 9s vs
nothing in 5 minutes. distill.py falls back automatically for models with no
think switch.
3. **Keep the reference block small.** >6 refs × 400 chars measurably worsens
deliberation spirals; retrieval `top_k` can stay high, but cap what you show
the writer.
4. **`sane_line()` is load-bearing.** Language-drift and fragment junk WILL
reach the DB without the ascii-ratio/length gate; purging after the fact is
`delete ... where length(regexp_replace(line,'[[:ascii:]]','','g')) > 3`.
5. **Resume counts must be per-writer-model** (`writer_model=%s` in the skip
query), or a failed model's junk "completes" topics and blocks the good
model from refilling them.