"""Extract a short narrative hook from every wiki/star-systems//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 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. It writes systems.db directly, which the asset-pipeline rule otherwise forbids; this edge is sanctioned by the D-262 flow diagram (`populate_gttr_hook -> systems.db`), and `gttr_hook` sits on `star_systems`, which `reach ledger import` does not clear. Formerly tooling/db/populate_gttr_hook.py (T-1290). """ from __future__ import annotations import re import sqlite3 from pathlib import Path from tooling.core import config, console from tooling.core.errors import ReachError REPO_ROOT = config.repo_root() DB_PATH = REPO_ROOT / "server" / "data" / "systems.db" WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems" # 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() # Drop an empty parenthetical left behind by the collapse — hygiene only. 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 run( db_path: Path = DB_PATH, *, system: str | None = None, max_words: int = 45, dry_run: bool = False, verbose: bool = False, ) -> dict[str, int]: """Populate star_systems.gttr_hook. Returns updated/missing/unmatched counts.""" if not db_path.exists(): raise ReachError( f"{db_path} not found", fix="pass --db with an existing systems.db, or restore it: git restore server/data/systems.db", ) 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 console.event( f"gttr-hook into {db_path} (max {max_words} words)" + (" — DRY RUN" if dry_run else "") ) rows = conn.execute("SELECT system_id, proper_name FROM star_systems ORDER BY system_id").fetchall() if system: rows = [r for r in rows if r[0] == system] if not rows: conn.close() raise ReachError( f"no system {system!r} in star_systems", fix="pass a system_id with its space, e.g. --system 'GJ 71'", ) counts = {"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, max_words) if hook is None: kind = "missing" if not gttr_path.exists() else "unmatched" counts[kind] += 1 if verbose: console.event(f"{kind.upper()} {system_id} {gttr_path}", level="warn") continue if verbose: display = f"{system_id} ({proper_name})" if proper_name else system_id console.out(f"{display}\n → {hook}") if not dry_run: conn.execute( "UPDATE star_systems SET gttr_hook = ? WHERE system_id = ?", (hook, system_id), ) counts["updated"] += 1 if dry_run: conn.rollback() else: conn.commit() except BaseException: conn.rollback() conn.close() raise conn.close() return counts