NPCvector/distill.py
type-two 2b3b6e8dfd distill: salvage well-formed strings from malformed writer arrays
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:31:04 +10:00

175 lines
7.2 KiB
Python
Executable File

#!/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]}")
try:
arr, _ = json.JSONDecoder().raw_decode(txt[start:])
except json.JSONDecodeError:
# salvage: models sometimes break an array element mid-list;
# harvest the well-formed quoted strings instead of losing all 12
arr = [json.loads(f'"{m}"') for m in
re.findall(r'"((?:[^"\\]|\\.){8,300})"', txt[start:])]
if not arr:
raise
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()