Files
settled-reach/tooling/db/populate_gttr_hook.py
T
jpmschweitzer a5fbce4cc9 feat(tooling): per-system gttr context + cosmopolitan corridor palettes (#833)
Two related quality fixes observed mid-run on Sirius + ACB + Ran:

1) Cosmopolitan corridor palettes. The six corridor inflection labels
were single-culture dominant ("administrative English / Gateway-era",
"British / Australian / Irish", "Korean/Japanese/Taiwanese", etc).
Gemma 2 2B interpreted these as "produce ONLY in this register" and
every core body came out anglophone, every east_reach body came out
East Asian. The real Earth diaspora in the setting is cosmopolitan —
a British surveyor on an east_reach moon still names a river after
their aunt in Dorset. The labels now spell out the dominant register
AND explicitly invite cross-cultural variety so Gemma samples from
the full few-shot pool instead of collapsing to one culture.

2) Per-system gttr context (the big one). The gttr.md files under
wiki/star-systems/<slug>/gttr.md already carry a vivid one-sentence
characterisation of every system — "where the rules live", "forty
years old and still in the draft", "the most connected system in
the Reach", "grandparents owned the land". This is a far stronger
cultural signal than the corridor inflection alone.

New column `star_systems.gttr_hook` stores a pre-extracted 45-word
hook per system. `tooling/db/populate_gttr_hook.py` parses each
gttr.md, regex-matches the first `**NAME**` paragraph, normalises
whitespace, truncates softly at a word cap, and stores it. Covers
all 301 systems (full coverage). Idempotent, safe to re-run after
any wiki update. Explicit transaction wrapper.

gemma_naming.py loads the hook cache at startup via
`load_system_gttr_hooks` and threads `system_hook` plus the system
and body proper names through process_body → name_feature →
_build_prompt. The prompt now carries:

    System: <proper_name>. Planet: <body_name>.
    About the system: <gttr_hook>

    Style: British. Answer: Cooper's Creek
    Style: Dutch.   Answer: Meijer Beek
    ...

Real-mode smoke on 10 cases across 4 contrasting systems shows the
hook is doing exactly what it should. Sample output on the same
body_id / local_id pairs:

  Tau Ceti     (cosmopolitan hub)         → Oakham River, Riverwood, Bridle Way
  Ran          (old-family agricultural)  → Hart's Well, Blackwood Ridge
  ACB          (Lattice Commission seat)  → Greenhaven, Rudge Brook
  Posto Avançado (PT frontier dead-end)   → Rio Preto, Serra de Caxias, Cunha's Cove

Posto Avançado went from "likely-English under the old corridor-only
prompt" to actual Portuguese names with a real Brazilian place stem
(Caxias), because the hook explicitly mentions wave_5 Portuguese
founders and frontier dead-end context. The gttr cultural one-liner
is the single strongest lever available for per-system cohesion —
this was the mono-culture issue observed in the first run, now fixed.

Token cost: ~60-90 extra tokens per prompt (hook + ident line).
Inference slowdown: ~5-10% per call. Acceptable for the quality gain.

Also restores 10 markers.json files that were stale from the aborted
run just killed — they were all core bodies at hop 0-1 which benefit
most from the gttr-context upgrade, so re-running them with the new
prompt is worth the ~3 minutes of re-inference.
2026-04-15 12:37:52 +02:00

216 lines
7.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Extract a short narrative hook from every wiki/star-systems/<slug>/gttr.md
and store it on `star_systems.gttr_hook`.
The GTTR ("Drifter's Guide to the Reach") files follow a consistent
format: an H1 title, then a paragraph that opens with `**NAME**` followed
by a 1-3 sentence characterisation. That characterisation is the hook —
per D-191 §4 and the #833 naming pipeline it is the single strongest
cultural signal available for each system, capturing things like:
- Gateway: "the most connected system in the Reach, the site of its
oldest research institution, and the location of a gate that goes
to Earth and that nobody uses"
- Ran: "a very nice place to live, provided you are the sort of
person whose grandparents owned the land"
- ACB: "where the Lattice Commission lives, which means it is where
the rules live"
- Posto Avançado: "forward post — the place beyond the established
line"
This script parses each gttr.md, regex-extracts the first `**NAME**`
paragraph, normalises whitespace, and truncates at a soft word cap so
the hook stays cheap to inject into prompts. Empty or unmatched files
leave the column NULL.
Idempotent, safe to re-run after any wiki update. Explicit transaction
wrapper with rollback on exception.
Usage:
tooling/db/populate_gttr_hook.py
tooling/db/populate_gttr_hook.py --max-words 45
tooling/db/populate_gttr_hook.py --dry-run
tooling/db/populate_gttr_hook.py --system "GJ 71"
"""
import argparse
import re
import sqlite3
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = (SCRIPT_DIR / ".." / "..").resolve()
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
# Matches the first paragraph that opens with `**NAME**` at the start
# of a line. Captures everything up to a blank line or the next H1/H2.
# Non-greedy on the content.
# Match a line that opens with `**NAME**` (any non-asterisk chars, since
# names contain accents from many alphabets — Á, Ž, Ç, Ñ, etc) followed
# by the paragraph body up to a blank line or the next markdown heading.
_HOOK_RE = re.compile(
r"^\*\*([^\n*]+?)\*\*(.+?)(?:\n\n|\n#)",
re.DOTALL | re.MULTILINE,
)
def _system_slug_to_system_id(slug: str) -> str:
"""Convert 'GJ-244A' → 'GJ 244A'. Mirrors populate_terrain_reference.py."""
if slug.startswith("GJ-"):
return "GJ " + slug[3:]
return slug
def extract_hook(gttr_path: Path, max_words: int) -> str | None:
"""Return a compact one-paragraph hook for this gttr.md, or None if
the file is missing or the opening paragraph can't be located."""
if not gttr_path.exists():
return None
text = gttr_path.read_text()
match = _HOOK_RE.search(text)
if not match:
return None
name_token = match.group(1).strip()
body = match.group(2).strip()
# Reconstruct "NAME is …" without the markdown asterisks. Add a
# single space between the name and the body since `.strip()` above
# removed the leading space from the captured body.
hook = f"{name_token} {body}"
# Collapse whitespace so multi-line paragraphs become one clean line.
hook = re.sub(r"\s+", " ", hook).strip()
# Strip a leading orphan "is" that comes from `**NAME**` + " is …":
# the regex captures the word "is" on its own because the opener is
# typically `**GATEWAY** (known as Tau Ceti...) is the most connected…`.
# Normal reading already works — this is just hygiene.
hook = re.sub(r"\s*\(\s*\)\s*", " ", hook)
# Hard cap at max_words. Truncate at the last word boundary before
# the cap and append an ellipsis so the reader knows it continues.
words = hook.split()
if len(words) > max_words:
hook = " ".join(words[:max_words]).rstrip(",;:") + "…"
return hook
def main():
parser = argparse.ArgumentParser(
description="Populate star_systems.gttr_hook from wiki gttr.md files"
)
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db")
parser.add_argument(
"--system",
help="Process only this system_id (e.g. 'GJ 71')",
)
parser.add_argument(
"--max-words",
type=int,
default=45,
help="Soft cap on hook length (default: 45 words)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Extract and print but do not write to the DB",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Print every extracted hook",
)
args = parser.parse_args()
db_path = Path(args.db)
if not db_path.exists():
print(f"error: {db_path} not found", file=sys.stderr)
sys.exit(1)
conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=15000")
conn.execute("PRAGMA foreign_keys=ON")
# Ensure the column exists — idempotent ADD COLUMN for old DBs.
try:
conn.execute("ALTER TABLE star_systems ADD COLUMN gttr_hook TEXT")
except sqlite3.OperationalError:
pass # column already exists
print(f"\n populate_gttr_hook.py")
print(f" DB: {db_path}")
print(f" max words: {args.max_words}")
if args.dry_run:
print(f" Mode: DRY RUN")
print()
rows = conn.execute(
"SELECT system_id, proper_name FROM star_systems ORDER BY system_id"
).fetchall()
if args.system:
rows = [r for r in rows if r[0] == args.system]
updated = 0
missing = 0
unmatched = 0
conn.execute("BEGIN")
try:
for system_id, proper_name in rows:
slug = system_id.replace(" ", "-", 1)
gttr_path = WIKI_SYSTEMS / slug / "gttr.md"
hook = extract_hook(gttr_path, args.max_words)
if hook is None:
if not gttr_path.exists():
missing += 1
if args.verbose:
print(f" MISSING {system_id:10s} {gttr_path}")
else:
unmatched += 1
if args.verbose:
print(f" UNMATCHED {system_id:10s} {gttr_path}")
continue
if args.verbose:
display = f"{system_id} ({proper_name})" if proper_name else system_id
print(f" {display}")
print(f" → {hook}")
if not args.dry_run:
conn.execute(
"UPDATE star_systems SET gttr_hook = ? WHERE system_id = ?",
(hook, system_id),
)
updated += 1
if args.dry_run:
conn.rollback()
else:
conn.commit()
except BaseException:
conn.rollback()
conn.close()
raise
conn.close()
print(f"\n Done:")
print(f" updated: {updated}")
print(f" missing: {missing}")
print(f" unmatched: {unmatched}")
if args.dry_run:
print(f"\n Dry run — no DB writes.")
print()
if __name__ == "__main__":
main()