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:
type-two 2026-07-18 21:19:31 +10:00
commit 07a0db6b61
9 changed files with 550 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
config.json
sft/
*.log
__pycache__/
.DS_Store

137
README.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);