feat(rulebook): port script — parse FabledRulebook .md → Scribe DB
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""One-shot operational scripts (not part of the running app)."""
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user