← Files QuestforgeARCHIVED FILE
scripts/rules_index.py
14.2 KB · Sep 30, 2026 · 23:13 UTC
"""Build and query a small local text index for SRD-style rules notes."""
from __future__ import annotations
import argparse
import json
import re
import sqlite3
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable
HEADING_PATTERN = re.compile(r"^(?P<marks>#{1,6})\s+(?P<title>.+?)\s*$")
TERM_PATTERN = re.compile(r"[a-z0-9]+", re.IGNORECASE)
@dataclass(frozen=True)
class RuleChunk:
"""A searchable chunk of rules text."""
source: str
heading: str
text: str
@dataclass(frozen=True)
class SearchResult:
"""A scored rules search result."""
score: int
chunk: RuleChunk
@dataclass(frozen=True)
class SetupIndex:
"""Resolved index paths from questforge-setup.json."""
language: str
sqlite_index_path: Path | None
jsonl_index_path: Path | None
resources_index_path: Path | None
def normalize_terms(text: str) -> set[str]:
return {term.lower() for term in TERM_PATTERN.findall(text)}
def split_markdown_sections(text: str, source: str) -> list[RuleChunk]:
"""Split Markdown-ish text into chunks by heading."""
chunks: list[RuleChunk] = []
current_heading = "Untitled"
current_lines: list[str] = []
for line in text.splitlines():
heading_match = HEADING_PATTERN.match(line)
if heading_match:
append_chunk(chunks, source, current_heading, current_lines)
current_heading = heading_match.group("title").strip()
current_lines = []
continue
current_lines.append(line)
append_chunk(chunks, source, current_heading, current_lines)
return chunks
def append_chunk(
chunks: list[RuleChunk],
source: str,
heading: str,
lines: list[str],
) -> None:
text = "\n".join(lines).strip()
if text:
chunks.append(RuleChunk(source=source, heading=heading, text=text))
def build_index(source_paths: Iterable[Path]) -> list[RuleChunk]:
chunks: list[RuleChunk] = []
for source_path in source_paths:
text = source_path.read_text(encoding="utf-8-sig")
chunks.extend(split_markdown_sections(text, source_path.name))
return chunks
def write_index(chunks: Iterable[RuleChunk], output_path: Path) -> None:
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("w", encoding="utf-8", newline="\n") as output_file:
for chunk in chunks:
output_file.write(json.dumps(asdict(chunk), ensure_ascii=False))
output_file.write("\n")
def write_sqlite_index(chunks: Iterable[RuleChunk], output_path: Path) -> None:
"""Write chunks to a small SQLite database with optional FTS5 search."""
output_path.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(output_path) as connection:
connection.execute("DROP TABLE IF EXISTS rules_chunks")
connection.execute("DROP TABLE IF EXISTS questforge_metadata")
connection.execute("DROP TABLE IF EXISTS rules_fts")
connection.execute("""
CREATE TABLE rules_chunks (
id INTEGER PRIMARY KEY,
source TEXT NOT NULL,
heading TEXT NOT NULL,
text TEXT NOT NULL
)
""")
connection.execute("""
CREATE TABLE questforge_metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
chunk_rows = [
(chunk.source, chunk.heading, chunk.text) for chunk in chunks
]
connection.executemany(
"""
INSERT INTO rules_chunks (source, heading, text)
VALUES (?, ?, ?)
""",
chunk_rows,
)
fts_enabled = create_fts_index(connection, chunk_rows)
connection.executemany(
"""
INSERT INTO questforge_metadata (key, value)
VALUES (?, ?)
""",
[
("schema", "questforge-rules-index-v1"),
("fts_enabled", "yes" if fts_enabled else "no"),
],
)
def create_fts_index(
connection: sqlite3.Connection,
chunk_rows: list[tuple[str, str, str]],
) -> bool:
"""Create an FTS5 table when the local SQLite build supports it."""
try:
connection.execute("""
CREATE VIRTUAL TABLE rules_fts
USING fts5(source UNINDEXED, heading, text)
""")
except sqlite3.OperationalError:
return False
connection.executemany(
"""
INSERT INTO rules_fts (source, heading, text)
VALUES (?, ?, ?)
""",
chunk_rows,
)
return True
def read_index(index_path: Path) -> list[RuleChunk]:
chunks: list[RuleChunk] = []
with index_path.open("r", encoding="utf-8") as index_file:
for line in index_file:
if not line.strip():
continue
payload = json.loads(line)
chunks.append(
RuleChunk(
source=payload["source"],
heading=payload["heading"],
text=payload["text"],
)
)
return chunks
def sqlite_has_fts(connection: sqlite3.Connection) -> bool:
cursor = connection.execute("""
SELECT name
FROM sqlite_master
WHERE type = 'table' AND name = 'rules_fts'
""")
return cursor.fetchone() is not None
def read_sqlite_index(index_path: Path) -> list[RuleChunk]:
with sqlite3.connect(index_path) as connection:
cursor = connection.execute("""
SELECT source, heading, text
FROM rules_chunks
ORDER BY id
""")
return [
RuleChunk(source=row[0], heading=row[1], text=row[2])
for row in cursor.fetchall()
]
def search_index(
chunks: Iterable[RuleChunk],
query: str,
limit: int = 5,
) -> list[SearchResult]:
query_terms = normalize_terms(query)
if not query_terms:
return []
results: list[SearchResult] = []
for chunk in chunks:
heading_terms = normalize_terms(chunk.heading)
text_terms = normalize_terms(chunk.text)
score = len(query_terms & text_terms) + (
2 * len(query_terms & heading_terms)
)
if score:
results.append(SearchResult(score=score, chunk=chunk))
return sorted(
results,
key=lambda result: (
-result.score,
result.chunk.source,
result.chunk.heading,
),
)[:limit]
def build_fts_query(query: str) -> str:
terms = sorted(normalize_terms(query))
return " OR ".join(terms)
def search_sqlite_index(
index_path: Path,
query: str,
limit: int = 5,
) -> list[SearchResult]:
fts_query = build_fts_query(query)
if not fts_query:
return []
with sqlite3.connect(index_path) as connection:
if sqlite_has_fts(connection):
cursor = connection.execute(
"""
SELECT source, heading, text, rank
FROM rules_fts
WHERE rules_fts MATCH ?
ORDER BY rank
LIMIT ?
""",
(fts_query, limit),
)
return [
SearchResult(
score=max(1, int(abs(row[3]) * 1_000_000)),
chunk=RuleChunk(
source=row[0],
heading=row[1],
text=row[2],
),
)
for row in cursor.fetchall()
]
return search_index(read_sqlite_index(index_path), query, limit)
def read_setup_index(manifest_path: Path) -> SetupIndex:
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
return SetupIndex(
language=str(payload.get("language", "")),
sqlite_index_path=optional_path(payload.get("sqlite_index_path")),
jsonl_index_path=optional_path(payload.get("jsonl_index_path")),
resources_index_path=optional_path(
payload.get("resources_index_path")
),
)
def optional_path(value: object) -> Path | None:
if not value:
return None
return Path(str(value))
def search_setup_index(
manifest_path: Path,
query: str,
limit: int = 5,
) -> tuple[SetupIndex, list[SearchResult]]:
setup_index = read_setup_index(manifest_path)
if (
setup_index.sqlite_index_path
and setup_index.sqlite_index_path.exists()
):
return setup_index, search_sqlite_index(
setup_index.sqlite_index_path,
query,
limit,
)
if setup_index.jsonl_index_path and setup_index.jsonl_index_path.exists():
return setup_index, search_index(
read_index(setup_index.jsonl_index_path),
query,
limit,
)
raise FileNotFoundError(
"No usable rules index found in setup manifest. "
"Rerun questforge_setup.py."
)
def result_to_dict(result: SearchResult) -> dict[str, object]:
return {
"score": result.score,
"source": result.chunk.source,
"heading": result.chunk.heading,
"text": result.chunk.text,
}
def print_results(
results: Iterable[SearchResult],
output_format: str = "text",
max_chars: int = 600,
) -> None:
results = list(results)
if output_format == "json":
print(
json.dumps(
[result_to_dict(result) for result in results],
indent=2,
ensure_ascii=False,
)
)
return
for result in results:
print(f"Reference: {result.chunk.source} :: {result.chunk.heading}")
print(f"Score: {result.score}")
print(result.chunk.text[:max_chars].strip())
print()
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Build or query a rules index."
)
subparsers = parser.add_subparsers(dest="command", required=True)
build_parser = subparsers.add_parser("build", help="Build a JSONL index.")
build_parser.add_argument(
"--source",
action="append",
required=True,
type=Path,
help="UTF-8 Markdown or text source. Repeat for multiple files.",
)
build_parser.add_argument("--output", required=True, type=Path)
build_parser.add_argument(
"--sqlite-output",
type=Path,
help="Optional SQLite index output with FTS5 when available.",
)
query_parser = subparsers.add_parser("query", help="Query a JSONL index.")
query_parser.add_argument("--index", required=True, type=Path)
query_parser.add_argument("--query", required=True)
query_parser.add_argument("--limit", type=int, default=5)
query_parser.add_argument(
"--format",
choices=("text", "json"),
default="text",
)
query_parser.add_argument("--max-chars", type=int, default=600)
sqlite_query_parser = subparsers.add_parser(
"query-sqlite",
help="Query a SQLite rules index.",
)
sqlite_query_parser.add_argument("--index", required=True, type=Path)
sqlite_query_parser.add_argument("--query", required=True)
sqlite_query_parser.add_argument("--limit", type=int, default=5)
sqlite_query_parser.add_argument(
"--format",
choices=("text", "json"),
default="text",
)
sqlite_query_parser.add_argument("--max-chars", type=int, default=600)
setup_query_parser = subparsers.add_parser(
"query-setup",
help="Query rules through questforge-setup.json.",
)
setup_query_parser.add_argument(
"--manifest",
default=".questforge/questforge-setup.json",
type=Path,
)
setup_query_parser.add_argument("--query", required=True)
setup_query_parser.add_argument("--limit", type=int, default=5)
setup_query_parser.add_argument(
"--format",
choices=("text", "json"),
default="text",
)
setup_query_parser.add_argument("--max-chars", type=int, default=600)
return parser
def main(arguments: Iterable[str] | None = None) -> int:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
parser = build_parser()
parsed_arguments = parser.parse_args(arguments)
if parsed_arguments.command == "build":
missing_sources = [
source_path
for source_path in parsed_arguments.source
if not source_path.exists()
]
if missing_sources:
missing_text = ", ".join(str(path) for path in missing_sources)
parser.error(f"Missing source file(s): {missing_text}")
chunks = build_index(parsed_arguments.source)
write_index(chunks, parsed_arguments.output)
if parsed_arguments.sqlite_output:
write_sqlite_index(chunks, parsed_arguments.sqlite_output)
print(f"Wrote {len(chunks)} chunks to {parsed_arguments.output}")
if parsed_arguments.sqlite_output:
print(f"Wrote SQLite index to {parsed_arguments.sqlite_output}")
return 0
if parsed_arguments.command == "query-setup":
if not parsed_arguments.manifest.exists():
parser.error(
f"Missing setup manifest: {parsed_arguments.manifest}"
)
try:
_, results = search_setup_index(
parsed_arguments.manifest,
parsed_arguments.query,
parsed_arguments.limit,
)
except FileNotFoundError as error:
parser.error(str(error))
print_results(
results,
parsed_arguments.format,
parsed_arguments.max_chars,
)
return 0
if not parsed_arguments.index.exists():
parser.error(f"Missing index file: {parsed_arguments.index}")
if parsed_arguments.command == "query-sqlite":
results = search_sqlite_index(
parsed_arguments.index,
parsed_arguments.query,
parsed_arguments.limit,
)
else:
chunks = read_index(parsed_arguments.index)
results = search_index(
chunks,
parsed_arguments.query,
parsed_arguments.limit,
)
print_results(results, parsed_arguments.format, parsed_arguments.max_chars)
return 0
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: fdabdc0fb9c0e36bfd00ea57c86e4cc9e29f5cb73d5125877109ec7824c9cfef