feat(rulebook): port script — parse FabledRulebook .md → Scribe DB

This commit is contained in:
2026-05-27 22:39:50 -04:00
parent 9658e9a35c
commit 5f3da7c004
5 changed files with 254 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""One-shot operational scripts (not part of the running app)."""
+185
View File
@@ -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()