Source code for ractogateway.prompts.engine

"""RACTO Prompt Engine — structured, anti-hallucination prompt compilation.

The ``RactoPrompt`` model enforces the RACTO principle:

    **R** ole  — Who the model is.
    **A** im   — What it must accomplish.
    **C** onstraints — Hard boundaries it must never violate.
    **T** one  — Communication style.
    **O** utput — The exact shape of the expected response.
"""

from __future__ import annotations

import base64
import json
import mimetypes
import textwrap
from pathlib import Path
from typing import Any, Union, cast

from pydantic import BaseModel, Field, model_validator

# ---------------------------------------------------------------------------
# Sentinel for "no default"
# ---------------------------------------------------------------------------


class _Unset:
    """Internal sentinel — distinguishes 'user passed None' from 'not set'."""


# ---------------------------------------------------------------------------
# File attachment support
# ---------------------------------------------------------------------------

#: MIME types treated as images by every provider.
_IMAGE_MIMES: frozenset[str] = frozenset(
    {"image/jpeg", "image/png", "image/gif", "image/webp"}
)


[docs] class RactoFile: """A file attachment that can be passed to :meth:`RactoPrompt.to_messages`. Create from a file path (MIME type is auto-detected) or directly from raw bytes with an explicit MIME type. Parameters ---------- data: Raw bytes of the file. mime_type: MIME type string, e.g. ``"image/jpeg"`` or ``"application/pdf"``. name: Optional filename hint used for display / debugging. Examples -------- >>> # From a file path >>> img = RactoFile.from_path("/tmp/photo.jpg") >>> # From bytes >>> img = RactoFile.from_bytes(open("photo.jpg", "rb").read(), "image/jpeg") """ def __init__(self, data: bytes, mime_type: str, name: str = "") -> None: self.data = data self.mime_type = mime_type self.name = name # ------------------------------------------------------------------ # Constructors # ------------------------------------------------------------------
[docs] @classmethod def from_path(cls, path: str | Path) -> RactoFile: """Load a file from *path* and auto-detect its MIME type. Parameters ---------- path: Absolute or relative path to the file on disk. Raises ------ FileNotFoundError If *path* does not exist. """ p = Path(path) mime_type, _ = mimetypes.guess_type(str(p)) if mime_type is None: mime_type = "application/octet-stream" return cls(data=p.read_bytes(), mime_type=mime_type, name=p.name)
[docs] @classmethod def from_bytes(cls, data: bytes, mime_type: str, name: str = "") -> RactoFile: """Create a :class:`RactoFile` directly from *data* bytes. Parameters ---------- data: Raw file bytes. mime_type: MIME type of the data, e.g. ``"image/png"``. name: Optional filename string (no file I/O is performed). """ return cls(data=data, mime_type=mime_type, name=name)
# ------------------------------------------------------------------ # Properties # ------------------------------------------------------------------ @property def base64_data(self) -> str: """Return file bytes encoded as a base-64 ASCII string.""" return base64.b64encode(self.data).decode("ascii") @property def is_image(self) -> bool: """True when the MIME type is a supported image type.""" return self.mime_type in _IMAGE_MIMES @property def is_pdf(self) -> bool: return self.mime_type == "application/pdf" @property def is_text(self) -> bool: return self.mime_type.startswith("text/") def __repr__(self) -> str: label = self.name or "<unnamed>" return f"RactoFile(name={label!r}, mime_type={self.mime_type!r}, bytes={len(self.data)})"
# --------------------------------------------------------------------------- # Provider-specific content-block builders # --------------------------------------------------------------------------- def _build_openai_content( user_message: str, attachments: list[RactoFile], ) -> str | list[dict[str, Any]]: """Return OpenAI-compatible user content with optional file attachments. Images become ``image_url`` blocks using an inline ``data:`` URI. Text files are embedded as ``text`` blocks. All other file types are embedded as a ``data:`` URI so vision-capable models can still attempt to process them. """ if not attachments: return user_message parts: list[dict[str, Any]] = [] for f in attachments: if f.is_image or not (f.is_text or f.is_pdf): # Images *and* any binary non-text file → data URI image_url block. parts.append( { "type": "image_url", "image_url": {"url": f"data:{f.mime_type};base64,{f.base64_data}"}, } ) else: # Plain-text / unknown text: decode and embed as a text block. parts.append( { "type": "text", "text": f.data.decode("utf-8", errors="replace"), } ) parts.append({"type": "text", "text": user_message}) return parts def _build_anthropic_content( user_message: str, attachments: list[RactoFile], ) -> str | list[dict[str, Any]]: """Return Anthropic-compatible user content with optional file attachments. * Images → ``image`` content blocks (base-64 source). * PDFs → ``document`` content blocks (base-64 source). * Text → ``text`` content blocks (decoded string). * Other → ``text`` block containing the base-64 payload with a label. """ if not attachments: return user_message parts: list[dict[str, Any]] = [] for f in attachments: if f.is_image: parts.append( { "type": "image", "source": { "type": "base64", "media_type": f.mime_type, "data": f.base64_data, }, } ) elif f.is_pdf: parts.append( { "type": "document", "source": { "type": "base64", "media_type": "application/pdf", "data": f.base64_data, }, } ) elif f.is_text: parts.append( {"type": "text", "text": f.data.decode("utf-8", errors="replace")} ) else: label = f.name or "attachment" parts.append( { "type": "text", "text": ( f"[File: {label} ({f.mime_type}) — base64 encoded]\n{f.base64_data}" ), } ) parts.append({"type": "text", "text": user_message}) return parts def _build_google_content( user_message: str, attachments: list[RactoFile], ) -> str | list[dict[str, Any]]: """Return Google Gemini-compatible user content with optional file attachments. Text files become ``text`` parts; all other files become ``inline_data`` parts with base-64 encoded bytes and their MIME type. """ if not attachments: return user_message parts: list[dict[str, Any]] = [] for f in attachments: if f.is_text: parts.append({"text": f.data.decode("utf-8", errors="replace")}) else: parts.append( { "inline_data": { "mime_type": f.mime_type, "data": f.base64_data, } } ) parts.append({"text": user_message}) return parts # --------------------------------------------------------------------------- # Output format helpers # --------------------------------------------------------------------------- def _schema_from_model(model: type[BaseModel]) -> dict[str, Any]: """Extract a clean JSON Schema dict from a Pydantic v2 model. Strips only pydantic-internal bookkeeping keywords that carry no semantic meaning for the LLM (``title``, ``$schema``, ``default``, ``contentEncoding``, ``contentMediaType``). Constraint keywords such as ``minimum``/``maximum``/``minLength``/ ``maxLength``/``pattern``/``minItems``/``maxItems``/``uniqueItems``/ ``multipleOf``/``exclusiveMinimum``/``exclusiveMaximum`` are intentionally **kept** so the LLM can see the exact numeric and string bounds it must respect. The separate :func:`~ractogateway.adapters._openai_schema.sanitize_for_openai` function strips those keywords for the OpenAI API wire format — a distinct concern. """ _prompt_strip: frozenset[str] = frozenset( { "title", "$schema", "default", "contentEncoding", "contentMediaType", } ) def _strip(node: Any) -> Any: if isinstance(node, list): return [_strip(item) for item in node] if isinstance(node, dict): return {k: _strip(v) for k, v in node.items() if k not in _prompt_strip} return node result = _strip(model.model_json_schema()) if not isinstance(result, dict): raise TypeError("model_json_schema() must return a JSON object at the root") return cast("dict[str, Any]", result) def _render_output_block(output_format: str | type[BaseModel]) -> str: """Return the OUTPUT section content for the compiled prompt.""" if isinstance(output_format, type) and issubclass(output_format, BaseModel): schema = _schema_from_model(output_format) schema_json = json.dumps(schema, indent=2) return ( "Respond ONLY with valid JSON that conforms exactly to the " "following JSON Schema. Do NOT wrap the JSON in markdown code " "fences or add any text before or after it.\n\n" f"JSON Schema:\n{schema_json}" ) tag = output_format.strip().lower() if tag == "json": return ( "Respond ONLY with valid JSON. Do NOT wrap the response in " "markdown code fences (```json … ```) or add any commentary " "before or after the JSON object." ) if tag == "markdown": return "Respond in well-structured Markdown." if tag == "text": return "Respond in plain text with no special formatting." # Free-form format description provided by the user. return f"Respond using the following format:\n{output_format}" # --------------------------------------------------------------------------- # Core model # ---------------------------------------------------------------------------
[docs] class RactoPrompt(BaseModel): """A strictly validated RACTO prompt definition. Parameters ---------- role: A sentence (or short paragraph) describing **who** the LLM is. aim: A clear statement of the task objective. constraints: Hard rules the model must obey. At least one is required. tone: The desired communication style (e.g. "Professional and concise"). output_format: Either a format keyword (``"json"``, ``"text"``, ``"markdown"``), a free-form format description, or a **Pydantic model class** whose JSON Schema will be embedded in the prompt. context: Optional extra context paragraph injected between AIM and CONSTRAINTS. Useful for passing domain-specific background knowledge that the model needs to reason about. examples: Optional list of example input/output pairs that are included in the prompt to steer the model via few-shot learning. anti_hallucination: When *True* (the default), the compiler appends explicit anti-hallucination directives at the end of the prompt. Notes ----- Legacy compatibility: Older RactoGateway versions accepted ``instructions`` without the full RACTO field set. That shape is still accepted and mapped to ``aim`` with sensible defaults for missing RACTO fields. """ role: str = Field( ..., min_length=1, description="Who the model is (e.g. 'You are a senior Python engineer').", ) aim: str = Field( ..., min_length=1, description="A clear statement of the task objective.", ) constraints: list[str] = Field( ..., min_length=1, description="Hard rules the model must obey. Minimum one constraint.", ) tone: str = Field( ..., min_length=1, description="Desired communication style.", ) output_format: Union[str, type[BaseModel]] = Field( # noqa: UP007 ..., description=( "A format keyword ('json', 'text', 'markdown'), a free-form " "description, or a Pydantic BaseModel class." ), ) context: str | None = Field( default=None, description="Optional domain-specific background knowledge.", ) examples: list[dict[str, str]] | None = Field( default=None, description=( "Optional few-shot examples. Each dict should have 'input' and 'output' keys." ), ) anti_hallucination: bool = Field( default=True, description="Append anti-hallucination directives to the prompt.", ) # Allow arbitrary types so that `type[BaseModel]` passes validation. model_config = {"arbitrary_types_allowed": True} # ------------------------------------------------------------------ # Validators # ------------------------------------------------------------------ @model_validator(mode="before") @classmethod def _coerce_legacy_fields(cls, data: Any) -> Any: if not isinstance(data, dict): return data payload = dict(data) legacy_instructions = payload.pop("instructions", None) if isinstance(legacy_instructions, str): payload.setdefault("aim", legacy_instructions) payload.setdefault("role", "You are a helpful assistant.") payload.setdefault("constraints", ["Do not fabricate information."]) payload.setdefault("tone", "Clear and concise.") return payload @model_validator(mode="after") def _validate_constraints_not_empty_strings(self) -> RactoPrompt: for idx, c in enumerate(self.constraints): if not c.strip(): raise ValueError( f"constraints[{idx}] is blank. Every constraint must be a non-empty string." ) return self @model_validator(mode="after") def _validate_examples_shape(self) -> RactoPrompt: if self.examples is not None: for idx, ex in enumerate(self.examples): if "input" not in ex or "output" not in ex: raise ValueError( f"examples[{idx}] must contain both 'input' and " f"'output' keys. Got: {sorted(ex.keys())}" ) return self # ------------------------------------------------------------------ # Compilation # ------------------------------------------------------------------
[docs] def compile(self) -> str: """Compile the RACTO fields into an optimized system prompt string. The resulting prompt is structured into clearly delimited sections so that the LLM can parse each instruction block unambiguously. Returns ------- str A ready-to-use system prompt. """ sections: list[str] = [] # --- ROLE --- sections.append(f"[ROLE]\n{self.role}") # --- AIM --- sections.append(f"[AIM]\n{self.aim}") # --- CONTEXT (optional) --- if self.context: sections.append(f"[CONTEXT]\n{self.context}") # --- CONSTRAINTS --- constraint_lines = "\n".join(f"- {c}" for c in self.constraints) sections.append(f"[CONSTRAINTS]\n{constraint_lines}") # --- TONE --- sections.append(f"[TONE]\n{self.tone}") # --- OUTPUT --- output_block = _render_output_block(self.output_format) sections.append(f"[OUTPUT]\n{output_block}") # --- EXAMPLES (optional) --- if self.examples: example_parts: list[str] = [] for i, ex in enumerate(self.examples, start=1): example_parts.append( f"Example {i}:\n Input: {ex['input']}\n Output: {ex['output']}" ) sections.append("[EXAMPLES]\n" + "\n\n".join(example_parts)) # --- ANTI-HALLUCINATION FOOTER --- if self.anti_hallucination: sections.append( textwrap.dedent( """\ [GUARDRAILS] - If you are unsure or lack sufficient information, state it explicitly rather than guessing. - Do NOT fabricate facts, citations, URLs, statistics, or code that you cannot verify. - Stick strictly to what is asked. Do not add unrequested information. - If the answer requires assumptions, list each assumption explicitly before proceeding.""" ) ) return "\n\n".join(sections) + "\n"
# ------------------------------------------------------------------ # Convenience helpers # ------------------------------------------------------------------
[docs] def to_messages( self, user_message: str, *, attachments: list[RactoFile] | None = None, provider: str = "generic", ) -> list[dict[str, Any]]: """Return a ready-to-send message list for a given LLM provider. Parameters ---------- user_message: The end-user's query or input. attachments: Optional list of :class:`RactoFile` objects to send alongside the text message. Accepted inputs per file: * **File path** — use :meth:`RactoFile.from_path`:: RactoFile.from_path("/tmp/diagram.png") * **Raw bytes** — use :meth:`RactoFile.from_bytes`:: RactoFile.from_bytes(img_bytes, "image/png") Each file is re-encoded into the content-block schema expected by the target provider (``image_url`` for OpenAI, ``image`` / ``document`` for Anthropic, ``inline_data`` for Google, ``images`` list for Ollama). provider: One of ``"openai"``, ``"anthropic"``, ``"google"``, ``"ollama"``, or ``"generic"``. Controls the system-role key name and the content-block format used for attachments. Returns ------- list[dict[str, Any]] A list of message dicts suitable for the provider's API. """ system_prompt = self.compile() files = attachments or [] if provider in ("openai", "generic"): return [ {"role": "system", "content": system_prompt}, {"role": "user", "content": _build_openai_content(user_message, files)}, ] if provider == "anthropic": # Anthropic uses "system" as a top-level param, but for message # list representation we use the same structure — the adapter # will unpack it. return [ {"role": "system", "content": system_prompt}, { "role": "user", "content": _build_anthropic_content(user_message, files), }, ] if provider == "google": # Gemini separates system_instruction from contents. # The adapter will split this; we use a marker role. return [ {"role": "system", "content": system_prompt}, {"role": "user", "content": _build_google_content(user_message, files)}, ] if provider == "ollama": # Ollama vision models accept a top-level ``images`` list of # raw base-64 strings alongside the text ``content`` field. # Non-image attachments are appended as text in the content. images: list[str] = [f.base64_data for f in files if f.is_image] text_parts: list[str] = [ f.data.decode("utf-8", errors="replace") for f in files if f.is_text ] combined_content = user_message if text_parts: combined_content = "\n\n".join(text_parts) + "\n\n" + user_message user_msg: dict[str, Any] = {"role": "user", "content": combined_content} if images: user_msg["images"] = images return [ {"role": "system", "content": system_prompt}, user_msg, ] raise ValueError( f"Unknown provider {provider!r}. " f"Expected one of: 'openai', 'anthropic', 'google', 'ollama', 'generic'." )
def __str__(self) -> str: """Return the compiled prompt when cast to str.""" return self.compile()