| """ |
| skill_generator.py |
| ------------------ |
| Dynamic Skill Generator for Notiflow. |
| |
| Creates new business skill Python files on demand and registers them in |
| skills/skill_registry.json. |
| |
| Public API |
| ---------- |
| generate_skill(skill_name: str, description: str) -> dict |
| list_skills() -> dict |
| |
| Safety rules: |
| - Raises SkillAlreadyExistsError if a skill with the same name exists. |
| - Skill names are normalised to snake_case. |
| - Generated files follow the standard skill template. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import logging |
| import re |
| from pathlib import Path |
| from typing import Optional |
|
|
| from app.config import ROOT, REGISTRY_FILE |
|
|
| logger = logging.getLogger(__name__) |
|
|
| SKILLS_DIR = ROOT / "skills" |
|
|
|
|
| |
| |
| |
|
|
| class SkillAlreadyExistsError(Exception): |
| """Raised when a skill with the given name already exists.""" |
|
|
|
|
| |
| |
| |
|
|
| _SKILL_TEMPLATE = '''\ |
| """ |
| {skill_name}.py |
| {underline} |
| Auto-generated business skill for Notiflow. |
| |
| Description: {description} |
| |
| Modify this file to implement the skill logic. |
| """ |
| |
| from __future__ import annotations |
| |
| import logging |
| from datetime import datetime, timezone |
| |
| logger = logging.getLogger(__name__) |
| |
| |
| def {func_name}(data: dict) -> dict: |
| """ |
| Execute the {display_name} skill. |
| |
| Args: |
| data: Validated extraction dict from the orchestrator. |
| |
| Returns: |
| Structured skill event dict. |
| """ |
| logger.info("{display_name} skill executing: %s", data) |
| |
| return {{ |
| "event": "{event_name}", |
| "data": data, |
| "timestamp": datetime.now(timezone.utc).isoformat(), |
| }} |
| ''' |
|
|
|
|
| |
| |
| |
|
|
| def _to_snake_case(name: str) -> str: |
| """Normalise skill name to snake_case (alphanumeric + underscores only).""" |
| name = name.strip().lower() |
| name = re.sub(r"[^a-z0-9]+", "_", name) |
| name = re.sub(r"_+", "_", name).strip("_") |
| return name |
|
|
|
|
| def _load_registry() -> dict: |
| path = Path(REGISTRY_FILE) |
| if not path.exists(): |
| return {} |
| try: |
| with path.open("r", encoding="utf-8") as f: |
| return json.load(f) |
| except (json.JSONDecodeError, OSError) as exc: |
| logger.warning("Could not read registry: %s", exc) |
| return {} |
|
|
|
|
| def _save_registry(registry: dict) -> None: |
| path = Path(REGISTRY_FILE) |
| path.parent.mkdir(parents=True, exist_ok=True) |
| with path.open("w", encoding="utf-8") as f: |
| json.dump(registry, f, indent=2, ensure_ascii=False) |
|
|
|
|
| |
| |
| |
|
|
| def generate_skill(skill_name: str, description: str) -> dict: |
| """ |
| Generate a new skill file and register it. |
| |
| Args: |
| skill_name: Human-readable name (e.g. "discount_skill" or "Discount Skill"). |
| Normalised to snake_case automatically. |
| description: One-line description stored in the registry. |
| |
| Returns: |
| Registry entry dict for the new skill: |
| { |
| "description": str, |
| "intent": None, |
| "file": "skills/<name>.py", |
| "builtin": false |
| } |
| |
| Raises: |
| SkillAlreadyExistsError: If a skill with the same name already exists |
| (either as a .py file or registry entry). |
| ValueError: If skill_name is empty or invalid. |
| |
| Example: |
| >>> generate_skill("discount_skill", "Apply discount to an order") |
| {"description": "Apply discount...", "file": "skills/discount_skill.py", ...} |
| """ |
| norm_name = _to_snake_case(skill_name) |
| if not norm_name: |
| raise ValueError(f"Invalid skill name: {skill_name!r}") |
|
|
| skill_file = SKILLS_DIR / f"{norm_name}.py" |
| registry = _load_registry() |
|
|
| |
| if norm_name in registry: |
| raise SkillAlreadyExistsError( |
| f"Skill '{norm_name}' already exists in the registry. " |
| "Choose a different name or delete the existing entry first." |
| ) |
| if skill_file.exists(): |
| raise SkillAlreadyExistsError( |
| f"Skill file '{skill_file}' already exists on disk. " |
| "Choose a different name or delete the existing file first." |
| ) |
|
|
| |
| display_name = norm_name.replace("_", " ").title() |
| func_name = norm_name |
| event_name = f"{norm_name}_executed" |
| underline = "-" * (len(norm_name) + 3) |
|
|
| source = _SKILL_TEMPLATE.format( |
| skill_name = norm_name, |
| underline = underline, |
| description = description, |
| func_name = func_name, |
| display_name = display_name, |
| event_name = event_name, |
| ) |
|
|
| SKILLS_DIR.mkdir(parents=True, exist_ok=True) |
| skill_file.write_text(source, encoding="utf-8") |
| logger.info("Skill file created: %s", skill_file) |
|
|
| |
| entry = { |
| "description": description, |
| "intent": None, |
| "file": f"skills/{norm_name}.py", |
| "builtin": False, |
| } |
| registry[norm_name] = entry |
| _save_registry(registry) |
| logger.info("Skill '%s' registered.", norm_name) |
|
|
| return entry |
|
|
|
|
| def list_skills() -> dict: |
| """ |
| Return the full skill registry. |
| |
| Returns: |
| Dict mapping skill_name β registry entry. |
| """ |
| return _load_registry() |