From 5f3da7c0047bf6496c8384331c97605395352d43 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 22:39:50 -0400 Subject: [PATCH] =?UTF-8?q?feat(rulebook):=20port=20script=20=E2=80=94=20p?= =?UTF-8?q?arse=20FabledRulebook=20.md=20=E2=86=92=20Scribe=20DB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/__init__.py | 1 + scripts/import_fabledrulebook.py | 185 ++++++++++++++++++ .../fixtures/fabledrulebook_sample/README.md | 3 + .../fabledrulebook_sample/git-workflow.md | 19 ++ tests/test_import_fabledrulebook.py | 46 +++++ 5 files changed, 254 insertions(+) create mode 100644 scripts/__init__.py create mode 100644 scripts/import_fabledrulebook.py create mode 100644 tests/fixtures/fabledrulebook_sample/README.md create mode 100644 tests/fixtures/fabledrulebook_sample/git-workflow.md create mode 100644 tests/test_import_fabledrulebook.py diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..5017c42 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""One-shot operational scripts (not part of the running app).""" diff --git a/scripts/import_fabledrulebook.py b/scripts/import_fabledrulebook.py new file mode 100644 index 0000000..6ee3505 --- /dev/null +++ b/scripts/import_fabledrulebook.py @@ -0,0 +1,185 @@ +"""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 fabledassistant.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() diff --git a/tests/fixtures/fabledrulebook_sample/README.md b/tests/fixtures/fabledrulebook_sample/README.md new file mode 100644 index 0000000..e96b6d3 --- /dev/null +++ b/tests/fixtures/fabledrulebook_sample/README.md @@ -0,0 +1,3 @@ +# Sample Rulebook + +This is a fixture for testing the port script. diff --git a/tests/fixtures/fabledrulebook_sample/git-workflow.md b/tests/fixtures/fabledrulebook_sample/git-workflow.md new file mode 100644 index 0000000..1fdf4b6 --- /dev/null +++ b/tests/fixtures/fabledrulebook_sample/git-workflow.md @@ -0,0 +1,19 @@ +# git-workflow + +Lead paragraph describing the topic. + +## dev is home + +Work directly on dev; no feature branches. + +**Why:** It minimises ceremony for a solo developer. + +**How to apply:** Default for all repo work. Switch to a feature branch only when explicitly asked. + +## no-force-push + +Never force-push to main. + +**Why:** Force-pushing rewrites shared history. + +**How to apply:** Anytime you're about to run `git push --force`, stop and check the target branch. diff --git a/tests/test_import_fabledrulebook.py b/tests/test_import_fabledrulebook.py new file mode 100644 index 0000000..ed6e73a --- /dev/null +++ b/tests/test_import_fabledrulebook.py @@ -0,0 +1,46 @@ +"""Tests for scripts/import_fabledrulebook.py — parser fixtures only. + +The CLI / DB-writing portion is exercised manually on remote dev — it's a +one-shot script. +""" +from pathlib import Path + + +FIXTURES = Path(__file__).parent / "fixtures" / "fabledrulebook_sample" + + +def test_parse_topic_file_extracts_topic_title_and_rules(): + from scripts.import_fabledrulebook import parse_topic_file + topic_title, description, rules = parse_topic_file(FIXTURES / "git-workflow.md") + + assert topic_title == "git-workflow" + assert "Lead paragraph" in description + assert len(rules) == 2 + assert rules[0]["title"] == "dev is home" + assert "Work directly on dev" in rules[0]["statement"] + assert "minimises ceremony" in rules[0]["why"] + assert "Default for all repo work" in rules[0]["how_to_apply"] + + +def test_parse_rule_body_without_why_treats_whole_body_as_statement(): + from scripts.import_fabledrulebook import parse_rule_body + out = parse_rule_body("test", "Just a body with no markers.") + assert out["statement"] == "Just a body with no markers." + assert out["why"] == "" + assert out["how_to_apply"] == "" + + +def test_parse_rule_body_handles_only_why(): + from scripts.import_fabledrulebook import parse_rule_body + out = parse_rule_body("test", "Statement.\n\n**Why:** Because.") + assert out["statement"] == "Statement." + assert out["why"] == "Because." + assert out["how_to_apply"] == "" + + +def test_parse_rule_body_handles_only_how_to_apply(): + from scripts.import_fabledrulebook import parse_rule_body + out = parse_rule_body("test", "Statement.\n\n**How to apply:** Do it now.") + assert out["statement"] == "Statement." + assert out["why"] == "" + assert out["how_to_apply"] == "Do it now."