Source code for dartwork_mpl.prompt

"""Prompt guide file management module.

Provides helper functions for finding, reading, listing, and copying
the prompt guide files bundled with the dartwork package.
"""

from __future__ import annotations

__all__ = [
    "copy_prompt",
    "find_template",
    "get_prompt",
    "list_prompts",
    "prompt_path",
]

import json
import warnings
from pathlib import Path
from shutil import copy2

from ._helpers import create_parent_path

_PROMPT_DIR: Path = Path(__file__).parent / "asset/prompt"

# Canonical prompt corpus (T5/R3a). ``list_prompts`` warns when a
# supported prompt file outside this set appears next to it. The
# ``05-templates/`` subdirectory is a separate surface with its own
# loader and is intentionally not listed here.
_CANONICAL_PROMPTS: frozenset[str] = frozenset(
    {"00-index", "01-policy", "02-anti-patterns", "03-recipes"}
)
_PROMPT_SUFFIXES: tuple[str, ...] = (".md", ".yaml")


[docs] def prompt_path(name: str) -> Path: """Get the absolute path to a prompt guide file. Parameters ---------- name : str Name of the prompt guide to retrieve (e.g., ``'00-index'``, ``'01-policy'``, ``'02-anti-patterns'``, ``'03-recipes'``). Returns ------- Path Path to the prompt guide file. Raises ------ ValueError If the specified guide cannot be found in the library. """ for suffix in _PROMPT_SUFFIXES: path: Path = _PROMPT_DIR / f"{name}{suffix}" if path.exists(): return path valid = list_prompts() valid_msg = f" Valid names: {valid}." if valid else "" raise ValueError(f"Prompt guide not found: {name}.{valid_msg}")
[docs] def get_prompt(name: str) -> str: """Read a prompt guide file and return its full content as a string. Parameters ---------- name : str Name of the prompt guide to read. Returns ------- str The content of the prompt guide. """ path = prompt_path(name) return path.read_text(encoding="utf-8")
[docs] def list_prompts() -> list[str]: """List the bundled prompt guide files. The canonical corpus is exactly four entries — ``00-index``, ``01-policy``, ``02-anti-patterns`` (YAML), ``03-recipes`` — plus the ``05-templates/`` subdirectory listed separately. If extra supported prompt files appear (stale leftovers, an in-progress addition, etc.), this function still returns them but emits a :class:`UserWarning` so drift surfaces immediately. Returns ------- list[str] Sorted list of available prompt guide names. """ if not _PROMPT_DIR.exists(): return [] found = sorted( { path.stem for suffix in _PROMPT_SUFFIXES for path in _PROMPT_DIR.glob(f"*{suffix}") } ) unexpected = [name for name in found if name not in _CANONICAL_PROMPTS] if unexpected: warnings.warn( "Unexpected prompt guide(s) found alongside the canonical " f"corpus: {unexpected}. The canonical set is " f"{sorted(_CANONICAL_PROMPTS)}. Either fold the content into " "an existing canonical file or extend _CANONICAL_PROMPTS in " "dartwork_mpl/prompt.py.", UserWarning, stacklevel=2, ) return found
_TEMPLATE_INDEX_PATH: Path = _PROMPT_DIR / "05-templates" / "_index.json" def find_template( intent: str, top_k: int = 5, tier: str | None = None ) -> list[dict[str, object]]: """Rank the bundled AI plot templates against a free-text intent. Mirrors the MCP ``find_template`` tool so the same ranking is reachable natively from Python without the MCP server. Each template's metadata text (``use_case`` + ``data_shape`` + ``difficulty`` + ``tags``) is scanned for occurrences of every whitespace-separated lowercase token in ``intent``; the count of matched tokens is the score. Parameters ---------- intent : str Free-text description, e.g. ``"horizontal bar comparison"``. top_k : int, optional Maximum number of matches to return, by default 5. tier : str | None, optional ``"basic"`` (default-equivalent) scans only the tier-1 minimal templates. ``"advanced"`` scans only the tier-2 narrative templates (story-led titles, reference lines, value labels). ``"all"`` (or any other non-None string) scans both tiers and returns ``"tier"`` on each match so the caller can tell which bucket a hit came from. ``None`` (default) preserves the original surface: basic tier only, no ``"tier"`` field. Returns ------- list[dict] Each match is ``{"template_id", "score", **metadata}``. When ``tier`` is non-``None`` each entry also carries ``"tier"`` so callers using ``"all"`` can distinguish. Empty list when ``intent`` is blank or no template overlaps. """ if not _TEMPLATE_INDEX_PATH.exists(): return [] index = json.loads(_TEMPLATE_INDEX_PATH.read_text(encoding="utf-8")) tokens = [t for t in intent.lower().split() if t] if not tokens: return [] # Build the (template_id, meta, source_tier) iter based on tier arg. advanced_section: dict[str, dict[str, object]] = index.get("advanced", {}) def _basic_items() -> list[tuple[str, dict[str, object], str]]: return [ (tid, meta, "basic") for tid, meta in index.items() if tid != "advanced" ] def _advanced_items() -> list[tuple[str, dict[str, object], str]]: return [ (tid, meta, "advanced") for tid, meta in advanced_section.items() ] if tier is None or tier == "basic": items = _basic_items() elif tier == "advanced": items = _advanced_items() else: # "all" (or any other string) -> both tiers. items = _basic_items() + _advanced_items() scored: list[tuple[int, str, dict[str, object], str]] = [] for template_id, meta, source_tier in items: raw_tags = meta.get("tags", []) tag_str = ( " ".join(str(t) for t in raw_tags) if isinstance(raw_tags, list) else str(raw_tags) ) haystack = " ".join( [ str(meta.get("use_case", "")), str(meta.get("data_shape", "")), str(meta.get("difficulty", "")), tag_str, str(meta.get("narrative", "")), ] ).lower() score = sum(1 for t in tokens if t in haystack) if score > 0: scored.append((score, template_id, meta, source_tier)) scored.sort(key=lambda item: (-item[0], item[1])) # Preserve the original output shape when no tier arg was supplied — # callers that pre-date the tier system don't suddenly see a new # field they don't know about. if tier is None: return [ {"template_id": template_id, "score": score, **meta} for score, template_id, meta, _ in scored[:top_k] ] return [ { "template_id": template_id, "score": score, "tier": source_tier, **meta, } for score, template_id, meta, source_tier in scored[:top_k] ]
[docs] def copy_prompt(name: str, destination: str | Path) -> Path: """Copy a bundled prompt guide file to the specified destination. Parameters ---------- name : str Name of the prompt guide to copy. destination : str | Path Destination path. If a directory, the file is copied with its original name and real extension. If a file path, that name is used. Returns ------- Path Absolute path of the newly copied file. Raises ------ ValueError If the source prompt guide cannot be found. """ source_path = prompt_path(name) dest_path = Path(destination) if dest_path.is_dir() or (not dest_path.exists() and not dest_path.suffix): dest_path = dest_path / source_path.name create_parent_path(dest_path) copy2(source_path, dest_path) return dest_path