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>
This commit is contained in:
commit
07a0db6b61
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
config.json
|
||||||
|
sft/
|
||||||
|
*.log
|
||||||
|
__pycache__/
|
||||||
|
.DS_Store
|
||||||
137
README.md
Normal file
137
README.md
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
# 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.
|
||||||
28
config.example.json
Normal file
28
config.example.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"db": "dbname=discogs_full",
|
||||||
|
"embed": {
|
||||||
|
"url": "http://127.0.0.1:11434",
|
||||||
|
"model": "nomic-embed-text"
|
||||||
|
},
|
||||||
|
"writer": {
|
||||||
|
"abliterated": false,
|
||||||
|
"stock": {
|
||||||
|
"url": "http://100.69.21.128:11434",
|
||||||
|
"model": "qwen2.5:7b"
|
||||||
|
},
|
||||||
|
"abliterix": {
|
||||||
|
"url": "http://127.0.0.1:11434",
|
||||||
|
"model": "hf.co/OBLITERATUS/gemma-4-E4B-it-OBLITERATED:latest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"retrieval": {
|
||||||
|
"sources": [
|
||||||
|
"soulstrut_post",
|
||||||
|
"vgplus_post"
|
||||||
|
],
|
||||||
|
"top_k": 12,
|
||||||
|
"min_len": 40,
|
||||||
|
"max_len": 800
|
||||||
|
},
|
||||||
|
"lines_per_topic": 12
|
||||||
|
}
|
||||||
36
debug_writer.py
Normal file
36
debug_writer.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
#!/opt/homebrew/bin/python3
|
||||||
|
"""One-shot debug: send the exact production prompt, dump response stats."""
|
||||||
|
import json, sys
|
||||||
|
import requests, psycopg2
|
||||||
|
import distill as D
|
||||||
|
|
||||||
|
persona = sys.argv[1] if len(sys.argv) > 1 else "milkbar_kid"
|
||||||
|
conn = psycopg2.connect(D.CFG["db"]); cur = conn.cursor()
|
||||||
|
topic = D.PERSONAS[persona]["topics"][0]
|
||||||
|
refs = D.retrieve(cur, D.embed(topic), 12, 40, 800)
|
||||||
|
w = D.writer_cfg()
|
||||||
|
ref_block = "\n---\n".join(r[2].strip()[:400] for r in refs[:6])
|
||||||
|
n = D.CFG["lines_per_topic"]
|
||||||
|
sys_p = ("You write dialogue for NPCs in a 90s Australian shopping-town video game. "
|
||||||
|
"You are given real forum posts as a VOICE AND ATTITUDE reference. "
|
||||||
|
"Never copy, quote, or lightly rephrase the reference text; never use usernames, "
|
||||||
|
"real names, URLs, or identifiable stories from it. Write ORIGINAL lines only.")
|
||||||
|
voice = D.PERSONAS[persona]["voice"]
|
||||||
|
user_p = (f"CHARACTER: {voice}\n\nSITUATION: {topic}\n\n"
|
||||||
|
f"VOICE REFERENCE (do not copy from this):\n{ref_block}\n\n"
|
||||||
|
f"Write {n} distinct things this character might SAY OUT LOUD in this situation. "
|
||||||
|
f"One or two sentences each, spoken register, no stage directions, no numbering baggage. "
|
||||||
|
f"Answer as a JSON array of {n} strings and nothing else.")
|
||||||
|
r = requests.post(w["url"] + "/v1/chat/completions", timeout=900, json={
|
||||||
|
"model": w["model"], "temperature": 0.85, "max_tokens": 4096,
|
||||||
|
"messages": [{"role": "system", "content": sys_p},
|
||||||
|
{"role": "user", "content": user_p}]})
|
||||||
|
d = r.json()
|
||||||
|
ch = d["choices"][0]
|
||||||
|
msg = ch["message"]
|
||||||
|
print("finish:", ch.get("finish_reason"),
|
||||||
|
"| content:", len(msg.get("content") or ""),
|
||||||
|
"| reasoning:", len(msg.get("reasoning") or ""))
|
||||||
|
print("usage:", d.get("usage"))
|
||||||
|
print("content head:", (msg.get("content") or "")[:300])
|
||||||
|
print("reasoning tail:", (msg.get("reasoning") or "")[-400:])
|
||||||
166
distill.py
Executable file
166
distill.py
Executable file
@ -0,0 +1,166 @@
|
|||||||
|
#!/opt/homebrew/bin/python3
|
||||||
|
"""GABGOD distill — mine the forum corpus for VOICE, write ORIGINAL NPC lines.
|
||||||
|
|
||||||
|
For each (persona, topic): embed the topic (nomic on m4pro), pull the top-k
|
||||||
|
most-similar forum posts from pgvector (style reference only), then ask the
|
||||||
|
writer LLM for `lines_per_topic` original lines in the persona's voice.
|
||||||
|
Lines land in gabgod.line_bank (approved = NULL until John reviews).
|
||||||
|
|
||||||
|
Resume-safe: a (persona, topic) that already has >= lines_per_topic lines is
|
||||||
|
skipped. Heartbeat: ~/.jobs/gabgod-distill.status.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./distill.py # everything in personas.json
|
||||||
|
./distill.py --persona record_keeper # one persona
|
||||||
|
./distill.py --persona record_keeper --topics 2 --dry-run # smoke test
|
||||||
|
"""
|
||||||
|
import argparse, json, re, sys, time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import psycopg2, requests
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
CFG = json.loads((HERE / "config.json").read_text())
|
||||||
|
PERSONAS = json.loads((HERE / "personas.json").read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def heartbeat(done, total, note=""):
|
||||||
|
Path.home().joinpath(".jobs").mkdir(exist_ok=True)
|
||||||
|
Path.home().joinpath(".jobs/gabgod-distill.status").write_text(
|
||||||
|
f"{datetime.now():%Y-%m-%d %H:%M:%S} | {done}/{total} | {note}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def writer_cfg():
|
||||||
|
w = CFG["writer"]
|
||||||
|
return w["abliterix"] if w.get("abliterated") else w["stock"]
|
||||||
|
|
||||||
|
|
||||||
|
def embed(text):
|
||||||
|
r = requests.post(f"{CFG['embed']['url']}/api/embeddings",
|
||||||
|
json={"model": CFG["embed"]["model"], "prompt": text}, timeout=60)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()["embedding"]
|
||||||
|
|
||||||
|
|
||||||
|
def retrieve(cur, vec, k, min_len, max_len):
|
||||||
|
"""Top-k similar posts across the configured corpus tables."""
|
||||||
|
lit = "[" + ",".join(f"{x:.6f}" for x in vec) + "]"
|
||||||
|
rows = []
|
||||||
|
for table in CFG["retrieval"]["sources"]:
|
||||||
|
cur.execute(
|
||||||
|
f"""select post_id, author, body_text, '{table.split('_')[0]}' src
|
||||||
|
from {table}
|
||||||
|
where embedding is not null
|
||||||
|
and length(body_text) between %s and %s
|
||||||
|
order by embedding <=> %s::vector
|
||||||
|
limit %s""",
|
||||||
|
(min_len, max_len, lit, k))
|
||||||
|
rows += cur.fetchall()
|
||||||
|
# keep global top-k by re-ranking is overkill; interleave and cap
|
||||||
|
return rows[: k]
|
||||||
|
|
||||||
|
|
||||||
|
def ask_writer(persona, topic, refs):
|
||||||
|
w = writer_cfg()
|
||||||
|
ref_block = "\n---\n".join(r[2].strip()[:400] for r in refs[:6]) # >6x400 sends reasoning models into a death spiral
|
||||||
|
n = CFG["lines_per_topic"]
|
||||||
|
sys_p = (
|
||||||
|
"You write dialogue for NPCs in a 90s Australian shopping-town video game. "
|
||||||
|
"You are given real forum posts as a VOICE AND ATTITUDE reference. "
|
||||||
|
"Never copy, quote, or lightly rephrase the reference text; never use usernames, "
|
||||||
|
"real names, URLs, or identifiable stories from it. Write ORIGINAL lines only.")
|
||||||
|
user_p = (
|
||||||
|
f"CHARACTER: {PERSONAS[persona]['voice']}\n\n"
|
||||||
|
f"SITUATION: {topic}\n\n"
|
||||||
|
f"VOICE REFERENCE (do not copy from this):\n{ref_block}\n\n"
|
||||||
|
f"Write {n} distinct things this character might SAY OUT LOUD in this situation. "
|
||||||
|
f"One or two sentences each, spoken register, no stage directions, no numbering baggage. "
|
||||||
|
f'Answer as a JSON array of {n} strings and nothing else.')
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(3):
|
||||||
|
# native ollama endpoint: think=False stops reasoning models burning the
|
||||||
|
# whole token budget deliberating (supergemma wrote 12k chars of drafts
|
||||||
|
# and no answer via /v1). 400 fallback for models without a think switch.
|
||||||
|
payload = {"model": w["model"], "stream": False, "think": False,
|
||||||
|
"options": {"temperature": 0.85, "num_predict": 2048},
|
||||||
|
"messages": [{"role": "system", "content": sys_p},
|
||||||
|
{"role": "user", "content": user_p}]}
|
||||||
|
r = requests.post(f"{w['url']}/api/chat", timeout=900, json=payload)
|
||||||
|
if r.status_code == 400:
|
||||||
|
payload.pop("think", None)
|
||||||
|
r = requests.post(f"{w['url']}/api/chat", timeout=900, json=payload)
|
||||||
|
r.raise_for_status()
|
||||||
|
txt = r.json()["message"]["content"]
|
||||||
|
try:
|
||||||
|
start = txt.find("[")
|
||||||
|
if start < 0:
|
||||||
|
raise ValueError(f"no JSON array in writer output: {txt[:200]}")
|
||||||
|
arr, _ = json.JSONDecoder().raw_decode(txt[start:])
|
||||||
|
except (ValueError, json.JSONDecodeError) as e:
|
||||||
|
last_err = e
|
||||||
|
continue
|
||||||
|
lines = [s.strip() for s in arr if isinstance(s, str) and sane_line(s.strip())]
|
||||||
|
if lines:
|
||||||
|
return lines, w["model"]
|
||||||
|
last_err = ValueError("all lines rejected by sanity filter")
|
||||||
|
raise last_err
|
||||||
|
|
||||||
|
|
||||||
|
def sane_line(s):
|
||||||
|
"""Reject model garbage: wrong language, fragments, essays."""
|
||||||
|
non_ascii = sum(1 for ch in s if ord(ch) > 127)
|
||||||
|
return 8 <= len(s) <= 300 and non_ascii <= 3
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--persona")
|
||||||
|
ap.add_argument("--topics", type=int, help="only first N topics (smoke test)")
|
||||||
|
ap.add_argument("--dry-run", action="store_true", help="print, don't insert")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
names = [args.persona] if args.persona else list(PERSONAS)
|
||||||
|
jobs = [(p, t) for p in names for t in
|
||||||
|
(PERSONAS[p]["topics"][: args.topics] if args.topics else PERSONAS[p]["topics"])]
|
||||||
|
|
||||||
|
conn = psycopg2.connect(CFG["db"])
|
||||||
|
conn.autocommit = True
|
||||||
|
cur = conn.cursor()
|
||||||
|
R = CFG["retrieval"]
|
||||||
|
target = CFG["lines_per_topic"]
|
||||||
|
|
||||||
|
done = 0
|
||||||
|
for persona, topic in jobs:
|
||||||
|
heartbeat(done, len(jobs), f"{persona} :: {topic[:50]}")
|
||||||
|
cur.execute("""select count(*) from gabgod.line_bank
|
||||||
|
where persona=%s and situation=%s and writer_model=%s""",
|
||||||
|
(persona, topic, writer_cfg()["model"]))
|
||||||
|
if cur.fetchone()[0] >= target and not args.dry_run:
|
||||||
|
done += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
refs = retrieve(cur, embed(topic), R["top_k"], R["min_len"], R["max_len"])
|
||||||
|
lines, model = ask_writer(persona, topic, refs)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FAIL {persona}/{topic[:40]}: {e}", file=sys.stderr)
|
||||||
|
heartbeat(done, len(jobs), f"FAIL {persona}: {e}")
|
||||||
|
time.sleep(5)
|
||||||
|
continue
|
||||||
|
ids = [r[0] for r in refs]
|
||||||
|
for ln in lines:
|
||||||
|
if args.dry_run:
|
||||||
|
print(f" [{persona}] {ln}")
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"""insert into gabgod.line_bank
|
||||||
|
(persona, situation, line, writer_model, source_post_ids)
|
||||||
|
values (%s,%s,%s,%s,%s) on conflict do nothing""",
|
||||||
|
(persona, topic, ln, model, ids))
|
||||||
|
done += 1
|
||||||
|
heartbeat(done, len(jobs), "DONE")
|
||||||
|
print(f"DONE {done}/{len(jobs)} persona-topics")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
50
export_sft.py
Executable file
50
export_sft.py
Executable file
@ -0,0 +1,50 @@
|
|||||||
|
#!/opt/homebrew/bin/python3
|
||||||
|
"""Export gabgod.line_bank -> SFT JSONL (mlx_lm.lora chat format) for retraining.
|
||||||
|
|
||||||
|
Only exports approved lines by default (--all to include unreviewed).
|
||||||
|
Writes sft/train.jsonl + sft/valid.jsonl (95/5 deterministic split).
|
||||||
|
|
||||||
|
./export_sft.py # approved only
|
||||||
|
./export_sft.py --all # everything (pre-review experiments)
|
||||||
|
"""
|
||||||
|
import argparse, json, hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
CFG = json.loads((HERE / "config.json").read_text())
|
||||||
|
PERSONAS = json.loads((HERE / "personas.json").read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--all", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
conn = psycopg2.connect(CFG["db"])
|
||||||
|
cur = conn.cursor()
|
||||||
|
where = "" if args.all else "where approved is true"
|
||||||
|
cur.execute(f"select persona, situation, line from gabgod.line_bank {where}")
|
||||||
|
|
||||||
|
out = HERE / "sft"
|
||||||
|
out.mkdir(exist_ok=True)
|
||||||
|
train = open(out / "train.jsonl", "w")
|
||||||
|
valid = open(out / "valid.jsonl", "w")
|
||||||
|
n = {"train": 0, "valid": 0}
|
||||||
|
for persona, situation, line in cur.fetchall():
|
||||||
|
rec = {"messages": [
|
||||||
|
{"role": "system",
|
||||||
|
"content": f"You are {persona.replace('_',' ')} in a 90s Australian shopping town. "
|
||||||
|
+ PERSONAS.get(persona, {}).get("voice", "")},
|
||||||
|
{"role": "user", "content": situation},
|
||||||
|
{"role": "assistant", "content": line}]}
|
||||||
|
bucket = "valid" if int(hashlib.sha1(line.encode()).hexdigest(), 16) % 20 == 0 else "train"
|
||||||
|
(valid if bucket == "valid" else train).write(json.dumps(rec) + "\n")
|
||||||
|
n[bucket] += 1
|
||||||
|
print(f"wrote {n['train']} train / {n['valid']} valid -> {out}/")
|
||||||
|
if not args.all and sum(n.values()) == 0:
|
||||||
|
print("0 approved lines — review first (./gabgod review) or use --all")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
31
gabgod
Executable file
31
gabgod
Executable file
@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# GABGOD CLI — corpus->NPC-line forge. Run from anywhere; lives in ~/Documents/GABGOD.
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
PSQL=/opt/homebrew/opt/postgresql@16/bin/psql
|
||||||
|
case "$1" in
|
||||||
|
abliterated) # admin toggle: gabgod abliterated on|off|status
|
||||||
|
case "$2" in
|
||||||
|
on) /opt/homebrew/bin/python3 - <<'EOF'
|
||||||
|
import json; p="config.json"; c=json.load(open(p)); c["writer"]["abliterated"]=True
|
||||||
|
json.dump(c, open(p,"w"), indent=2); print("abliterated: ON ->", c["writer"]["abliterix"]["model"])
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
off) /opt/homebrew/bin/python3 - <<'EOF'
|
||||||
|
import json; p="config.json"; c=json.load(open(p)); c["writer"]["abliterated"]=False
|
||||||
|
json.dump(c, open(p,"w"), indent=2); print("abliterated: OFF ->", c["writer"]["stock"]["model"])
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
*) /opt/homebrew/bin/python3 -c "
|
||||||
|
import json; c=json.load(open('config.json'))['writer']
|
||||||
|
m = c['abliterix'] if c['abliterated'] else c['stock']
|
||||||
|
print('abliterated:', 'ON' if c['abliterated'] else 'OFF', '->', m['model'], '@', m['url'])" ;;
|
||||||
|
esac ;;
|
||||||
|
distill) shift; ./distill.py "$@" ;;
|
||||||
|
status) cat ~/.jobs/gabgod-distill.status 2>/dev/null || echo "no job run yet" ;;
|
||||||
|
sample) # gabgod sample [persona] — random unreviewed lines
|
||||||
|
W=""; [ -n "$2" ] && W="where persona='$2'"
|
||||||
|
$PSQL -d discogs_full -c "select persona, situation, line from gabgod.line_bank $W order by random() limit 15" ;;
|
||||||
|
counts) $PSQL -d discogs_full -c "select persona, count(*) lines, count(*) filter (where approved) approved from gabgod.line_bank group by persona order by 1" ;;
|
||||||
|
export) shift; ./export_sft.py "$@" ;;
|
||||||
|
*) echo "usage: gabgod {abliterated on|off|status} | distill [...] | status | sample [persona] | counts | export [--all]" ;;
|
||||||
|
esac
|
||||||
78
personas.json
Normal file
78
personas.json
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"record_keeper": {
|
||||||
|
"shop": "record store (PROCITY)",
|
||||||
|
"voice": "Veteran record-store owner and lifelong digger. Dry, sarcastic, encyclopedic. Judges your taste but secretly wants you to leave with something good. Talks like a Soulstrut regular: reissue skepticism, pressing pedantry, war stories about scores and grails. Australian, 90s. Never mean to genuine beginners — saves the venom for flippers and know-it-alls.",
|
||||||
|
"topics": [
|
||||||
|
"customer asks if a record is an original pressing or a reissue",
|
||||||
|
"customer complains the record is too expensive",
|
||||||
|
"customer asks for a recommendation, what should I listen to",
|
||||||
|
"customer brings a beat-up scratched record to the counter",
|
||||||
|
"customer asks about a rare grail record, holy grail want list",
|
||||||
|
"customer mentions they resell records on ebay for profit",
|
||||||
|
"small talk about the weather or the shop being quiet",
|
||||||
|
"customer asks what the record playing in the shop right now is",
|
||||||
|
"customer asks about hip hop breaks and samples, who sampled this",
|
||||||
|
"customer haggling, will you take less, best price"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"opshop_keeper": {
|
||||||
|
"shop": "op shop / thrift (PROCITY)",
|
||||||
|
"voice": "Sweet, scattered volunteer op-shop lady of indeterminate age. Everything reminds her of a story. Prices are mysterious and non-negotiable in a gentle way. Occasionally devastatingly perceptive. Loves a chat, in no hurry whatsoever.",
|
||||||
|
"topics": [
|
||||||
|
"customer asks how much is this, item has no price tag",
|
||||||
|
"customer found something great hidden in a pile, a real score",
|
||||||
|
"customer asks if any records or tapes came in this week",
|
||||||
|
"small talk about the town, the neighbours, the old days",
|
||||||
|
"customer tries to haggle at an op shop",
|
||||||
|
"customer donates a box of old stuff, what people leave behind",
|
||||||
|
"customer asks about the strange object on the shelf nobody can identify"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pawnbroker": {
|
||||||
|
"shop": "pawnbroker (PROCITY)",
|
||||||
|
"voice": "Suspicious, seen-it-all pawnbroker. Every sentence weighs risk versus margin. Speaks in short bursts, always half-distracted by the cricket on a tiny TV. Rates everything in what it will actually move for, not what it is worth. Grudging respect for anyone who knows their stuff. Talks like the ebay/shipping/feedback threads on a record forum: postage grievances, lowballers, chancers.",
|
||||||
|
"topics": [
|
||||||
|
"customer wants to sell something, pawnbroker lowballs it",
|
||||||
|
"customer questions the price of an item in the case",
|
||||||
|
"customer asks if the gear is tested, does this thing even work",
|
||||||
|
"postage and shipping complaints, buyers overseas, packaging",
|
||||||
|
"lowballers and chancers and time wasters, tyre kickers",
|
||||||
|
"customer spots underpriced gold the pawnbroker missed",
|
||||||
|
"small talk, the cricket, slow day, the economy"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"video_rental_guy": {
|
||||||
|
"shop": "video rental (PROCITY)",
|
||||||
|
"voice": "Mid-20s film obsessive stuck behind the counter of a 90s video shop. Opinionated to a fault, quotes directors nobody has heard of, physically pained by popular taste. Weekly late fees are a moral issue. Would die for the staff picks shelf.",
|
||||||
|
"topics": [
|
||||||
|
"customer returns a tape late, late fees argument",
|
||||||
|
"customer asks for a recommendation, what should I rent tonight",
|
||||||
|
"customer wants the new release that is always out of stock",
|
||||||
|
"customer did not rewind the tape, be kind rewind",
|
||||||
|
"customer asks about the staff picks shelf, weird obscure films",
|
||||||
|
"small talk about movies, arguing about a popular blockbuster"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"milkbar_kid": {
|
||||||
|
"shop": "milk bar (PROCITY)",
|
||||||
|
"voice": "Teenager minding the family milk bar after school. Bored out of their mind, radio on, doing homework on the counter. Deadpan, economical, secretly funny. Knows everything that happens on the street and will trade gossip for nothing.",
|
||||||
|
"topics": [
|
||||||
|
"customer buys lollies, mixed bag, counting out five cent pieces",
|
||||||
|
"customer asks what is good here, milkshake flavours",
|
||||||
|
"small talk, school, being bored, the radio",
|
||||||
|
"gossip about the street, who was in earlier, what happened",
|
||||||
|
"customer asks for directions around town"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"book_barn_owner": {
|
||||||
|
"shop": "book barn (PROCITY)",
|
||||||
|
"voice": "Retired academic drowning happily in a barn of secondhand books. Cannot answer a simple question without a tangent. Genuinely delighted by curiosity, faintly disappointed by bestsellers. Knows exactly which pile everything is in, refuses to organize.",
|
||||||
|
"topics": [
|
||||||
|
"customer asks if you have a specific book, the filing system",
|
||||||
|
"customer asks for a recommendation, something to read",
|
||||||
|
"customer found a first edition or something valuable in a pile",
|
||||||
|
"small talk that turns into a lecture, tangents",
|
||||||
|
"customer asks why the shop is such a mess, the piles"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
19
schema.sql
Normal file
19
schema.sql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
-- GABGOD line bank — distilled, ORIGINAL NPC lines (never verbatim corpus text).
|
||||||
|
-- Lives in discogs_full next to game_corpus (the source it distills from).
|
||||||
|
create schema if not exists gabgod;
|
||||||
|
|
||||||
|
create table if not exists gabgod.line_bank (
|
||||||
|
id bigserial primary key,
|
||||||
|
persona text not null,
|
||||||
|
situation text not null,
|
||||||
|
line text not null,
|
||||||
|
writer_model text,
|
||||||
|
source_post_ids bigint[] default '{}', -- retrieval provenance (style refs, not text source)
|
||||||
|
source_forum text,
|
||||||
|
approved boolean, -- null = unreviewed; John's eyeball gate
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
unique (persona, situation, line)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists line_bank_persona_idx
|
||||||
|
on gabgod.line_bank (persona, situation);
|
||||||
Loading…
Reference in New Issue
Block a user