"""One-shot import of FabledRulebook .md files into the Scribe DB. Usage: python -m scripts.import_fabledrulebook \\ --source-dir /path/to/FabledRulebook \\ --user-id 1 \\ [--rulebook-title "FabledSword family"] \\ [--subscribe-projects 3,4,5] \\ [--dry-run] \\ [--force] Calls the services layer (services/rulebooks.py) — does not write raw SQL. This means any structural mismatch surfaces as a clean service-level ValueError. The script is not part of Alembic; the operator runs it once after migration 0055 lands. """ from __future__ import annotations import argparse import asyncio import logging import re from pathlib import Path RULE_HEADING_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE) WHY_RE = re.compile(r"\*\*\s*Why:?\s*\*\*", re.IGNORECASE) HOW_RE = re.compile(r"\*\*\s*How to apply:?\s*\*\*", re.IGNORECASE) H1_RE = re.compile(r"^#\s+.+$", re.MULTILINE) logger = logging.getLogger("import_fabledrulebook") def parse_rule_body(title: str, body: str) -> dict: """Extract statement, why, how_to_apply from a rule's H2-section body.""" body = body.strip() statement = body why = "" how_to_apply = "" why_match = WHY_RE.search(body) how_match = HOW_RE.search(body) if why_match: statement = body[: why_match.start()].strip() if how_match and how_match.start() > why_match.end(): why = body[why_match.end() : how_match.start()].strip() how_to_apply = body[how_match.end() :].strip() else: why = body[why_match.end() :].strip() elif how_match: statement = body[: how_match.start()].strip() how_to_apply = body[how_match.end() :].strip() return { "title": title, "statement": statement, "why": why, "how_to_apply": how_to_apply, } def parse_topic_file(path: Path) -> tuple[str, str, list[dict]]: """Returns (topic_title, topic_description, [rules]).""" text = path.read_text(encoding="utf-8") topic_title = path.stem # e.g. "git-workflow" parts = RULE_HEADING_RE.split(text) preamble = parts[0] if parts else "" # Strip H1 from preamble to get just the description. description = H1_RE.sub("", preamble, count=1).strip() rules: list[dict] = [] for i in range(1, len(parts), 2): rule_title = parts[i].strip() body = parts[i + 1] if i + 1 < len(parts) else "" rules.append(parse_rule_body(rule_title, body)) return topic_title, description, rules async def run(args) -> None: source = Path(args.source_dir) if not source.exists(): raise SystemExit(f"Source dir not found: {source}") md_files = sorted(p for p in source.glob("*.md") if p.name.lower() != "readme.md") if not md_files: raise SystemExit(f"No topic .md files found in {source}") readme = source / "README.md" description = readme.read_text(encoding="utf-8") if readme.exists() else "" if args.dry_run: print(f"DRY RUN — would import {len(md_files)} topic file(s):") for f in md_files: topic_title, _, rules = parse_topic_file(f) print(f" TOPIC: {topic_title} ({len(rules)} rules)") for r in rules: preview = r["statement"][:80] + ("…" if len(r["statement"]) > 80 else "") empty_flag = " [EMPTY STATEMENT — will skip]" if not r["statement"].strip() else "" print(f" - {r['title']}: {preview}{empty_flag}") return from scribe.services import rulebooks as rb_service existing = await rb_service.find_rulebook_by_title(args.user_id, args.rulebook_title) if existing is not None and not args.force: raise SystemExit( f"Rulebook '{args.rulebook_title}' already exists for user " f"{args.user_id}; pass --force to create another." ) rb = await rb_service.create_rulebook( user_id=args.user_id, title=args.rulebook_title, description=description, ) logger.info("Created rulebook %d ('%s')", rb.id, rb.title) rules_created = 0 rules_skipped = 0 for i, f in enumerate(md_files): topic_title, topic_desc, rules = parse_topic_file(f) topic = await rb_service.create_topic( rulebook_id=rb.id, user_id=args.user_id, title=topic_title, description=topic_desc, order_index=i, ) logger.info(" Created topic %d ('%s', %d rules)", topic.id, topic.title, len(rules)) for j, rule in enumerate(rules): if not rule["statement"].strip(): logger.warning(" SKIP empty-statement rule: %s / %s", topic_title, rule["title"]) rules_skipped += 1 continue await rb_service.create_rule( topic_id=topic.id, user_id=args.user_id, title=rule["title"], statement=rule["statement"], why=rule["why"], how_to_apply=rule["how_to_apply"], order_index=j, ) rules_created += 1 for pid in (args.subscribe_projects or []): await rb_service.subscribe_project( project_id=pid, rulebook_id=rb.id, user_id=args.user_id, ) logger.info("Subscribed project %d to rulebook %d", pid, rb.id) print( f"\nImported rulebook '{rb.title}' (id={rb.id}): " f"{len(md_files)} topics, {rules_created} rules created" + (f", {rules_skipped} skipped" if rules_skipped else "") ) def _comma_ints(s: str) -> list[int]: return [int(x) for x in s.split(",") if x.strip()] def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--source-dir", required=True, help="Path to the FabledRulebook directory") parser.add_argument("--user-id", type=int, required=True, help="Scribe user id who will own the rulebook") parser.add_argument("--rulebook-title", default="FabledSword family") parser.add_argument("--subscribe-projects", type=_comma_ints, help="Comma-separated project ids to auto-subscribe") parser.add_argument("--dry-run", action="store_true") parser.add_argument("--force", action="store_true", help="Allow creating a rulebook even if title exists") args = parser.parse_args() logging.basicConfig(level=logging.INFO, format="%(message)s") asyncio.run(run(args)) if __name__ == "__main__": main()