Source code for rapidtools.gui.prompt_builder

# Copyright (c) 2025 The University of Washington
#
# This file is part of rapidtools.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors
# may be used to endorse or promote products derived from this software without
# specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# You should have received a copy of the BSD 3-Clause License along with
# rapidtools. If not, see <http://www.opensource.org/licenses/>.
#
# Contributors:
# Barbaros Cetiner
#
# Last updated:
# 09-30-2026

"""
Structured prompt builder and language-model assistant for the rapidtools GUI.

The sample prompts shipped with rapidtools (for example the aerial CHS damage
prompt) share one layout: a ``TASK`` statement, numbered ``ANALYSIS STEPS``,
``ANALYSIS EDGE CASES``, a ``REQUIRED OUTPUT FORMAT`` block whose ``Key:
[options]`` lines the :class:`~rapidtools.processing.AssetAnalyzer` parses
into asset attributes, and a rubric describing every class with its key
indicators. :class:`PromptSpec` captures those parts as data,
:func:`assemble_prompt` renders them in that layout, and
:class:`PromptAssistant` asks any rapidtools model backend to draft, refine,
review or import such a specification.

The module has no heavy dependencies; the assistant only needs a model that
implements :meth:`~rapidtools.models.base.BaseInferenceModel.run_inference`
and accepts an empty image list, which every rapidtools wrapper does.

Example:
    >>> from rapidtools.gui.prompt_builder import (
    ...     OutputField, PromptSpec, RubricEntry, assemble_prompt
    ... )
    >>>
    >>> spec = PromptSpec(
    ...     asset='building',
    ...     objective='assign a damage level to the primary building',
    ...     fields=[
    ...         OutputField('Damage Level', options=['None', 'Minor', 'Major']),
    ...         OutputField('Justification', kind='text',
    ...                     description='2-3 sentences citing visual evidence'),
    ...     ],
    ...     rubric=[RubricEntry('Minor', 'Cosmetic damage',
    ...                         indicators=['Roof intact', 'Soot staining'])],
    ... )
    >>> print(assemble_prompt(spec).splitlines()[0])
    TASK: Analyze each provided image and assign a damage level to the primary building.
"""

from __future__ import annotations

import json
import logging
import re
from dataclasses import asdict, dataclass, field
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from rapidtools.models.base import BaseInferenceModel

logger = logging.getLogger(__name__)

FIELD_KINDS = ('category', 'number', 'text', 'boolean')
IMAGERY_KINDS = ('aerial', 'street', 'multi-temporal street')
ASSIST_ACTIONS = ('draft', 'refine', 'review', 'indicators', 'import')

# How the outline drawn by the imagery extractors is described to the model.
_SHAPE_WORDS = {
    'geometry': 'outline',
    'bbox': 'bounding box',
    'rotated_bbox': 'rotated bounding box',
    'convex_hull': 'outline',
    'corners': 'corner brackets',
}


def describe_marking(shape: str, color: str, overlay: bool = True) -> str:
    """
    Phrase the outline drawn on each crop the way the sample prompts do.

    Args:
        shape (str): One of the extractor ``outline_shape`` values.
        color (str): The PIL colour name or hex code used for the outline.
        overlay (bool): ``False`` when no outline is drawn.

    Returns:
        str: For example ``'roughly marked with a red outline'`` or
        ``'marked with cyan corner brackets'``; an empty string when nothing
        is drawn.

    Example:
        >>> describe_marking('corners', '#00ffff')
        'marked with #00ffff corner brackets'
        >>> describe_marking('bbox', 'cyan')
        'marked with a cyan bounding box'
        >>> describe_marking('geometry', 'red', overlay=False)
        ''
    """
    if not overlay:
        return ''
    word = _SHAPE_WORDS.get(shape, 'outline')
    colour = str(color).strip() or 'red'
    prefix = (
        'roughly marked with' if shape in ('geometry', 'convex_hull') else 'marked with'
    )
    article = '' if word.endswith('s') else 'a '
    return f'{prefix} {article}{colour} {word}'


def snake_case(name: str) -> str:
    """
    Turn an output field name into the attribute key the analyzer writes.

    Mirrors :func:`rapidtools.processing.image_analyzers.parse_structured_results`,
    which lower-cases ``Key: value`` lines and replaces spaces with
    underscores.

    Example:
        >>> snake_case('CHS Level')
        'chs_level'
    """
    return re.sub(r'\s+', '_', name.strip().lower())


[docs] @dataclass class OutputField: """ One line of the ``REQUIRED OUTPUT FORMAT`` block. Attributes: name: Field label as the model should write it (``'CHS Level'``). kind: ``'category'`` (choose one of ``options``), ``'number'`` (within ``minimum``..``maximum``), ``'text'`` or ``'boolean'``. options: Allowed values for a categorical field. minimum: Lower bound of a numeric field, if any. maximum: Upper bound of a numeric field, if any. description: How to fill the field (``'2-3 sentences citing ...'``). Example: >>> OutputField('Confidence', kind='number', minimum=1, maximum=3).format_line() 'Confidence: [number from 1 to 3]' """ name: str kind: str = 'category' options: list[str] = field(default_factory=list) minimum: float | None = None maximum: float | None = None description: str = '' def __post_init__(self) -> None: """Normalise the kind and strip whitespace from the options.""" self.name = ' '.join(str(self.name).split()) self.kind = str(self.kind or 'category').strip().lower() if self.kind not in FIELD_KINDS: raise ValueError(f'kind must be one of {FIELD_KINDS}, got {self.kind!r}.') self.options = [ ' '.join(str(o).split()) for o in self.options if str(o).strip() ] self.description = ' '.join(str(self.description or '').split()) @property def key(self) -> str: """The ``snake_case`` attribute key the analyzer will produce.""" return snake_case(self.name)
[docs] def placeholder(self) -> str: """The bracketed hint written after the field name.""" if self.kind == 'category' and self.options: hint = ', '.join(self.options) elif self.kind == 'number': if self.minimum is not None and self.maximum is not None: hint = f'number from {_fmt(self.minimum)} to {_fmt(self.maximum)}' else: hint = 'number' elif self.kind == 'boolean': hint = 'true or false' else: hint = self.description or 'free text' if self.kind != 'text' and self.description: hint = f'{hint}; {self.description}' return hint
[docs] def format_line(self) -> str: """Render the ``Name: [hint]`` line of the plain-text output format.""" return f'{self.name}: [{self.placeholder()}]'
[docs] def json_hint(self) -> Any: """Render the value shown in the JSON schema variant of the output.""" if self.kind == 'category' and self.options: hint = 'one of: ' + ', '.join(self.options) elif self.kind == 'number': hint = 'number' if self.minimum is not None and self.maximum is not None: hint += f' from {_fmt(self.minimum)} to {_fmt(self.maximum)}' elif self.kind == 'boolean': return 'true/false' else: hint = self.description or 'free text' if self.kind != 'text' and self.description: hint = f'{hint}; {self.description}' return hint
[docs] @dataclass class RubricEntry: """ Description of one class of the primary categorical field. Attributes: value: The option this entry explains (``'2'``). title: Short name (``'Partial Structural Combustion'``). description: What the class means. indicators: Visual cues that identify it, one per bullet. Example: >>> RubricEntry('4', 'Complete Combustion', indicators=['Only ash']).value '4' """ value: str title: str = '' description: str = '' indicators: list[str] = field(default_factory=list) def __post_init__(self) -> None: """Strip whitespace and drop empty indicators.""" self.value = ' '.join(str(self.value).split()) self.title = ' '.join(str(self.title or '').split()) self.description = str(self.description or '').strip() self.indicators = _clean_lines(self.indicators)
[docs] @dataclass class PromptSpec: """ Everything needed to assemble a prompt in the rapidtools sample layout. Attributes: asset: What is being analysed (``'building'``, ``'vehicle'``). imagery: ``'aerial'``, ``'street'`` or ``'multi-temporal street'``. objective: What the model must do, phrased after "Analyze each provided image and ..." (``'assign a CHS Level (0-4) to the primary building structure'``). persona: Expertise the model should adopt. focus: Scope statements such as what to ignore. marking: How the asset is marked on the crop, from :func:`describe_marking`. steps: Numbered analysis steps; generated when empty. edge_cases: Sentences describing alternative classifications. fields: The output fields, in order. rubric_title: Heading of the class descriptions block. rubric_field: Name of the field the rubric explains; defaults to the first categorical field. rubric: One :class:`RubricEntry` per class. context: Optional background paragraph appended at the end. json_output: Ask for a JSON object instead of ``Key: value`` lines. Example: >>> spec = PromptSpec.from_dict({'asset': 'road', 'fields': [ ... {'name': 'Blocked', 'kind': 'boolean'}]}) >>> spec.fields[0].format_line() 'Blocked: [true or false]' """ asset: str = 'building' imagery: str = 'aerial' objective: str = '' persona: str = '' focus: str = '' marking: str = '' steps: list[str] = field(default_factory=list) edge_cases: list[str] = field(default_factory=list) fields: list[OutputField] = field(default_factory=list) rubric_title: str = '' rubric_field: str = '' rubric: list[RubricEntry] = field(default_factory=list) context: str = '' json_output: bool = False def __post_init__(self) -> None: """Normalise strings and coerce nested dicts into dataclasses.""" self.asset = ' '.join(str(self.asset or 'building').split()) or 'building' self.imagery = str(self.imagery or 'aerial').strip().lower() if self.imagery not in IMAGERY_KINDS: self.imagery = 'aerial' for attr in ( 'objective', 'persona', 'focus', 'marking', 'rubric_title', 'rubric_field', ): setattr(self, attr, ' '.join(str(getattr(self, attr) or '').split())) # Models (and people) often restate the lead-in; keep only the goal. self.objective = re.sub( r'^(?:analy[sz]e|examine|look at)\s+(?:each|the|every|all)\s+' r'(?:provided\s+)?(?:series of\s+)?(?:street-level\s+|aerial\s+)?' r'images?\s+(?:and|to)\s+', '', self.objective, flags=re.IGNORECASE, ) self.rubric_title = self.rubric_title.rstrip(':') self.context = str(self.context or '').strip() self.steps = _clean_lines(self.steps) self.edge_cases = _clean_lines(self.edge_cases) self.fields = [ f if isinstance(f, OutputField) else OutputField(**_only_known(f, OutputField)) for f in self.fields if isinstance(f, OutputField) or (isinstance(f, dict) and str(f.get('name', '')).strip()) ] self.rubric = [ r if isinstance(r, RubricEntry) else RubricEntry(**_only_known(r, RubricEntry)) for r in self.rubric if isinstance(r, RubricEntry) or (isinstance(r, dict) and str(r.get('value', '')).strip()) ] self.json_output = bool(self.json_output) # ------------------------------------------------------------ helpers
[docs] @classmethod def from_dict(cls, data: dict[str, Any] | None) -> PromptSpec: """ Build a spec from a JSON object, ignoring unknown keys. Args: data (dict[str, Any] | None): Keys matching the attributes; nested ``fields`` and ``rubric`` entries may be dicts. Returns: PromptSpec: The coerced specification. Raises: ValueError: If ``data`` is not an object or a field kind is invalid. """ if data is None: data = {} if not isinstance(data, dict): raise ValueError('The prompt specification must be a JSON object.') return cls(**_only_known(data, cls))
[docs] def to_dict(self) -> dict[str, Any]: """Serialise to plain JSON-compatible data.""" return asdict(self)
[docs] def primary_field(self) -> OutputField | None: """ The field the rubric describes. Returns: OutputField | None: The field named by ``rubric_field`` if it exists, else the first categorical field, else ``None``. """ if self.rubric_field: for f in self.fields: if f.name.lower() == self.rubric_field.lower(): return f for f in self.fields: if f.kind == 'category': return f return None
[docs] def attribute_keys(self, prefix: str = '') -> list[str]: """ Attribute names the analyzer will write for this prompt. Args: prefix (str): The backend's attribute prefix (``'gemini'``). Returns: list[str]: ``'<prefix>_<key>'`` per field (``'<key>'`` without a prefix). """ return [f'{prefix}_{f.key}' if prefix else f.key for f in self.fields]
[docs] def default_steps(self) -> list[str]: """Analysis steps used when the user has not written any.""" marking = f' {self.marking}' if self.marking else '' primary = self.primary_field() steps = [f'Locate the primary {self.asset}{marking}'] if self.imagery == 'multi-temporal street': steps.append( 'Describe the state of the primary asset in each image before ' 'comparing them' ) described = any(r.title or r.description or r.indicators for r in self.rubric) if described and primary is not None: steps.append( f'Compare what you see against the {self.rubric_title or primary.name} ' 'indicators below' ) else: steps.append(f'Examine the visible condition of the {self.asset} in detail') if primary is not None: steps.append(f'Assign the {primary.name} with a brief justification') else: steps.append('Fill in every field of the required output format') return steps
def _fmt(value: float) -> str: """Format a bound without a trailing ``.0``.""" return str(int(value)) if float(value).is_integer() else str(value) def _clean_lines(items: Any) -> list[str]: """Coerce a list (or newline-separated string) into stripped lines.""" if isinstance(items, str): items = items.splitlines() return [ ' '.join(str(i).split()) for i in (items or []) if i is not None and str(i).strip() ] def _only_known(data: dict[str, Any], cls: type) -> dict[str, Any]: """Keep only the keys ``cls`` accepts, so foreign JSON does not crash.""" known = set(cls.__dataclass_fields__) # type: ignore[attr-defined] return {k: v for k, v in data.items() if k in known} # ================================================================ assembly
[docs] def assemble_prompt(spec: PromptSpec) -> str: """ Render a :class:`PromptSpec` in the layout of the rapidtools sample prompts. The result has these blocks, each omitted when it has nothing to say: ``TASK``, ``ANALYSIS STEPS``, ``ANALYSIS EDGE CASES``, ``REQUIRED OUTPUT FORMAT``, the rubric under ``rubric_title`` and ``CONTEXT AND BACKGROUND``. Args: spec (PromptSpec): The specification to render. Returns: str: The prompt text. Example: >>> from rapidtools.gui.prompt_builder import ( ... OutputField, PromptSpec, assemble_prompt ... ) >>> >>> text = assemble_prompt(PromptSpec( ... asset='vehicle', imagery='street', ... objective='classify the condition of the marked vehicle', ... fields=[OutputField('Condition', options=['intact', 'damaged'])], ... )) >>> 'Condition: [intact, damaged]' in text True """ blocks: list[str] = [] # TASK ----------------------------------------------------------------- task_bits: list[str] = [] image_word = { 'aerial': 'each provided image', 'street': 'each provided street-level image', 'multi-temporal street': 'the provided series of street-level images', }[spec.imagery] objective = ( spec.objective.rstrip('.') or f'describe the condition of the primary {spec.asset}' ) task_bits.append(f'Analyze {image_word} and {objective}.') if spec.focus: task_bits.append(_sentence(spec.focus)) if spec.persona: task_bits.append(f'Act as {_role_phrase(spec.persona)}.') blocks.append('TASK: ' + ' '.join(task_bits)) # ANALYSIS STEPS ------------------------------------------------------- steps = spec.steps or spec.default_steps() blocks.append( 'ANALYSIS STEPS:\n' + '\n'.join(f'{i}. {_strip_numbering(s)}' for i, s in enumerate(steps, 1)) ) # EDGE CASES ----------------------------------------------------------- if spec.edge_cases: blocks.append( 'ANALYSIS EDGE CASES:\n' + '\n'.join(_sentence(e) for e in spec.edge_cases) ) # OUTPUT FORMAT -------------------------------------------------------- if spec.fields: if spec.json_output: schema = {f.key: f.json_hint() for f in spec.fields} body = json.dumps(schema, indent=2, ensure_ascii=False) body = body.replace('"true/false"', 'true/false') blocks.append( 'REQUIRED OUTPUT FORMAT:\n' 'Return ONLY a valid JSON object matching this exact schema, with no ' 'markdown or text outside it:\n' + body ) else: blocks.append( 'REQUIRED OUTPUT FORMAT:\n' + '\n'.join(f.format_line() for f in spec.fields) ) # RUBRIC --------------------------------------------------------------- primary = spec.primary_field() described = [e for e in spec.rubric if e.title or e.description or e.indicators] if described: title = spec.rubric_title or ( f'{primary.name.upper()} DEFINITIONS' if primary else 'CLASS DEFINITIONS' ) label = primary.name if primary else 'Class' entries: list[str] = [] for entry in described: lines = [ f'{label} {entry.value}' + (f': {entry.title}' if entry.title else '') ] if entry.description: lines.append(f'Description: {entry.description}') if entry.indicators: lines.append('Key indicators:') lines.extend(f'- {ind}' for ind in entry.indicators) entries.append('\n'.join(lines)) blocks.append(f'{title.rstrip(":")}:\n\n' + '\n\n'.join(entries)) # CONTEXT -------------------------------------------------------------- if spec.context: blocks.append('CONTEXT AND BACKGROUND:\n' + spec.context) return '\n\n'.join(blocks).strip() + '\n'
def _role_phrase(persona: str) -> str: """ Turn a persona into the object of "Act as ...". ``'an expert in X'`` and ``'the lead assessor'`` are kept; a job title such as ``'Senior Damage Assessor'`` gets an article; a lower-case field such as ``'forensic engineering'`` becomes ``'an expert in forensic engineering'``. Example: >>> _role_phrase('Senior Damage Assessor') 'a Senior Damage Assessor' >>> _role_phrase('forensic engineering') 'an expert in forensic engineering' >>> _role_phrase('an insurance adjuster.') 'an insurance adjuster' """ persona = persona.strip().rstrip('.') if re.match(r'^(an?|the)\s', persona, re.IGNORECASE): return persona lowered = persona.lower() if 'expert' in lowered or re.search( r'(?:^|\s)(?:assessor|engineer|inspector|analyst|specialist|adjuster|' r'surveyor|officer|scientist|planner|professional|consultant)s?(?:\s|$)', lowered, ): article = 'an' if lowered[0] in 'aeiou' else 'a' return f'{article} {persona}' return f'an expert in {persona}' def _sentence(text: str) -> str: """Capitalise and terminate a sentence fragment.""" text = text.strip() if not text: return text text = text[0].upper() + text[1:] return text if text[-1] in '.!?:' else text + '.' def _strip_numbering(step: str) -> str: """Remove a leading ``1.`` / ``1)`` / ``-`` the user may have typed.""" return re.sub(r'^\s*(?:\d+[.)]|[-*•])\s*', '', step).strip() # ============================================================ sample spec SAMPLE_SPEC = PromptSpec( asset='building structure', imagery='aerial', objective=( 'assign a CHS Level (0, 1, 2, 3, 4) to the primary building structure, ' 'based on the CHS Combustion Index provided' ), persona=( 'an expert in forensic engineering, wildfire damage assessment, and ' 'combustible materials' ), focus='Do not focus your attention on non-combustible materials', marking='roughly marked with a red outline', steps=[ 'Locate the primary building footprint roughly marked with a red outline', 'Apply appropriate CHS indicators and combustion descriptions', 'Assign CHS Level with brief justification', ], edge_cases=[ 'Alternative assigned classifications for each image include "No Data", as ' 'outlined in the CHS COMBUSTION INDEX section, and "No Data-Footprint Unclear"', 'Assign a CHS Level of "No Data-Footprint Unclear" if the provided image does ' 'not appear to have a singular mostly complete building footprint. Look ' 'specifically for the presence of multiple complete or near-complete ' 'footprints', ], fields=[ OutputField( 'CHS Level', options=['0', '1', '2', '3', '4', 'No Data', 'No Data-Footprint Unclear'], ), OutputField( 'Justification', kind='text', description='2-3 sentences explaining key visual evidence', ), ], rubric_title='CHS COMBUSTION INDEX', rubric_field='CHS Level', rubric=[ RubricEntry( '0', 'Unaffected / No Direct Combustion', 'The structure was not directly ignited and did not participate in ' 'combustion.', [ 'Roof is fully intact with uniform color and texture', 'Building footprint is crisp and unbroken', 'No visible fire-related debris on the roof or property', ], ), RubricEntry( '1', 'Superficial Combustion & Radiant Heat Damage', 'Intense radiant heat or brief flame contact ignited only the most ' 'vulnerable ' 'exterior surfaces (paint, plastic siding, small decorative elements).', [ 'Visible soot staining or discoloration on the roof surface', 'Damage to plastic roof features like vents or skylight domes', 'The primary roof structure is not breached', ], ), RubricEntry( '2', 'Partial Structural Combustion', 'The fire breached the exterior envelope and caused sustained ' 'combustion of a ' 'limited portion of the internal structure.', [ 'A clear, distinct breach or hole in the roof exposing the interior ' 'below', 'A limited section of the roof is missing or has collapsed inward', 'The overall building footprint remains intact', ], ), RubricEntry( '3', 'Major Structural Combustion / Incomplete Reduction', 'The fire propagated through most of the structure, leaving charred ' 'skeletal remains.', [ 'The roof is almost entirely gone', 'The charred tops of standing interior and exterior walls are ' 'visible from above', 'A large debris pile is contained within the remaining skeletal walls', ], ), RubricEntry( '4', 'Complete Combustion / Full Reduction to Ash', 'Virtually all combustible structural mass was reduced to ash with no ' 'recognizable ' 'skeletal structure.', [ 'The skeletal structure is gone', 'The entire foundation footprint is filled with a homogenous pile of ' 'ash and debris', 'Only foundation or non-combustible walls (brick, masonry, stucco) ' 'remain', 'Non-combustible items such as chimney stacks or vehicle chassis may ' 'protrude from the ash', ], ), RubricEntry( 'No Data', 'Combustion level cannot be assessed', 'The site has been altered to an extent that its initial damage state ' 'cannot be analyzed.', [ 'Excess of dirt, indicating a bulldozed or demolished plot', 'Construction or demolition vehicles are present on site', ], ), ], ) # ============================================================== assistant _SPEC_SCHEMA = """{ "asset": "what is analysed, e.g. building", "imagery": "aerial | street | multi-temporal street", "objective": "what to do, phrased after 'Analyze each provided image and ...'", "persona": "expertise the model should adopt", "focus": "scope rules, e.g. what to ignore", "steps": ["numbered analysis steps"], "edge_cases": ["when to use alternative classes such as No Data"], "fields": [ {"name": "Field Name", "kind": "category | number | text | boolean", "options": ["for category fields"], "minimum": null, "maximum": null, "description": "how to fill it"} ], "rubric_title": "heading for the class definitions", "rubric_field": "name of the categorical field the rubric explains", "rubric": [ {"value": "option value", "title": "short name", "description": "what it means", "indicators": ["visual cues, one per item"]} ], "context": "optional background paragraph", "json_output": false }""" _ASSISTANT_ROLE = ( 'You help engineers write prompts for a vision-language model that grades ' 'infrastructure assets (buildings, roads, vehicles, poles) in post-disaster ' 'imagery, one cropped image at a time. Good prompts state the task and the ' "expert's role, number the analysis steps, list edge cases, fix an exact " 'output format whose fields become GIS attributes, and describe every class ' 'with concrete visual indicators that are observable in that kind of imagery.' )
[docs] class PromptAssistant: """ Use a rapidtools model backend to help write a :class:`PromptSpec`. Every method sends a text-only request through :meth:`~rapidtools.models.base.BaseInferenceModel.run_inference` with an empty image list and asks for JSON where a specification is expected. The reply is parsed leniently (code fences and surrounding prose are tolerated) and coerced with :meth:`PromptSpec.from_dict`. Args: model (BaseInferenceModel): Any loaded rapidtools model, for example ``rapidtools.models.load('gemma4')``. temperature (float): Sampling temperature for the requests. max_tokens (int): Generation budget; specifications with long rubrics need a few thousand tokens. Example: >>> from rapidtools.models import load >>> from rapidtools.gui.prompt_builder import PromptAssistant >>> >>> assistant = PromptAssistant(load('gemma4')) >>> spec = assistant.draft( ... 'Grade roof damage of houses after a hurricane into four levels', ... context={'asset': 'building', 'imagery': 'aerial'}, ... ) >>> [f.name for f in spec.fields] ['Damage Level', 'Justification'] """ def __init__( self, model: BaseInferenceModel, temperature: float = 0.2, max_tokens: int = 4096, ) -> None: """Bind the assistant to a loaded model.""" self.model = model self.temperature = temperature self.max_tokens = max_tokens # ------------------------------------------------------------ actions
[docs] def run(self, action: str, **kwargs: Any) -> dict[str, Any]: """ Dispatch one of :data:`ASSIST_ACTIONS` and return a JSON-safe result. Args: action (str): ``'draft'``, ``'refine'``, ``'review'``, ``'indicators'`` or ``'import'``. **kwargs: Forwarded to the matching method. Returns: dict[str, Any]: ``{'spec': {...}}`` for actions that produce a specification, ``{'text': ...}`` for ``review`` and ``{'indicators': [...]}`` for ``indicators``. Raises: ValueError: For an unknown action or an unusable model reply. """ if action == 'draft': return { 'spec': self.draft( kwargs.get('brief', ''), kwargs.get('context') ).to_dict() } if action == 'refine': spec = PromptSpec.from_dict(kwargs.get('spec')) return {'spec': self.refine(spec, kwargs.get('instruction', '')).to_dict()} if action == 'review': spec_data = kwargs.get('spec') text = kwargs.get('prompt_text') or ( assemble_prompt(PromptSpec.from_dict(spec_data)) if spec_data else '' ) return {'text': self.review(text)} if action == 'indicators': spec = PromptSpec.from_dict(kwargs.get('spec')) return { 'indicators': self.suggest_indicators( spec, kwargs.get('class_value', '') ) } if action == 'import': return {'spec': self.import_prompt(kwargs.get('prompt_text', '')).to_dict()} raise ValueError(f'Unknown assistant action: {action!r}')
[docs] def draft(self, brief: str, context: dict[str, Any] | None = None) -> PromptSpec: """ Draft a complete specification from a plain-English description. Args: brief (str): What the user wants to find out, in their words. context (dict[str, Any] | None): Known facts from the GUI such as ``asset``, ``imagery``, ``marking`` and ``json_output``. Returns: PromptSpec: The drafted specification. Raises: ValueError: If ``brief`` is empty or the reply holds no JSON object. """ if not brief.strip(): raise ValueError( 'Describe what you want to assess before asking for a draft.' ) context = context or {} prompt = ( f'{_ASSISTANT_ROLE}\n\n' 'Write a complete prompt specification for the request below. Use ' 'concrete, visually observable indicators; include at least three ' 'indicators per class; add edge cases for unclear or missing data; make ' 'the first field the main classification and the last field a short ' 'Justification. Return ONLY a JSON object with this schema:\n' f'{_SPEC_SCHEMA}\n\n' 'Known context (keep these values): ' f'{json.dumps(context, ensure_ascii=False)}\n\n' f'Request: {brief.strip()}' ) spec = self._spec_from_reply(self._ask(prompt, json_mode=True)) return _apply_context(spec, context)
[docs] def refine(self, spec: PromptSpec, instruction: str) -> PromptSpec: """ Change an existing specification following an instruction. Args: spec (PromptSpec): The current specification. instruction (str): What to change (``'add a confidence field 1-3'``). Returns: PromptSpec: The revised specification. Raises: ValueError: If ``instruction`` is empty or the reply is unusable. """ if not instruction.strip(): raise ValueError('Say what should change before asking for a refinement.') prompt = ( f'{_ASSISTANT_ROLE}\n\n' 'Revise the prompt specification below according to the instruction. ' 'Keep everything the instruction does not touch. Return ONLY the full ' 'revised JSON object with the same schema.\n\n' 'Current specification:\n' f'{json.dumps(spec.to_dict(), ensure_ascii=False, indent=1)}\n\n' f'Instruction: {instruction.strip()}' ) return self._spec_from_reply(self._ask(prompt, json_mode=True))
[docs] def review(self, prompt_text: str) -> str: """ Critique a prompt and suggest improvements. Args: prompt_text (str): The assembled prompt. Returns: str: A short review as plain text with bullet points. Raises: ValueError: If the prompt is empty or the model returned nothing. """ if not prompt_text.strip(): raise ValueError('There is no prompt to review yet.') prompt = ( f'{_ASSISTANT_ROLE}\n\n' 'Review the prompt below as a critical colleague. In at most eight short ' 'bullet points, name ambiguities, classes without observable indicators, ' 'missing edge cases, output-format problems (every field must be ' 'parseable as "Name: value" or JSON) and anything a vision model cannot ' 'see in a single cropped image. End with one sentence on overall ' 'readiness. Plain text only, no JSON.\n\n' f'PROMPT:\n{prompt_text.strip()}' ) text = self._ask(prompt, json_mode=False) if not text.strip(): raise ValueError('The model returned an empty review.') return text.strip()
[docs] def suggest_indicators(self, spec: PromptSpec, class_value: str) -> list[str]: """ Propose visual indicators for one class of the primary field. Args: spec (PromptSpec): The current specification (for asset, imagery and neighbouring classes). class_value (str): The option value to describe. Returns: list[str]: Three to six indicator sentences. Raises: ValueError: If ``class_value`` is empty or no list was returned. """ if not class_value.strip(): raise ValueError('Pick a class to suggest indicators for.') primary = spec.primary_field() existing = next((r for r in spec.rubric if r.value == class_value), None) prompt = ( f'{_ASSISTANT_ROLE}\n\n' f'Asset: {spec.asset}. Imagery: {spec.imagery}. Field: ' f'{primary.name if primary else "class"} with options ' f'{primary.options if primary else []}.\n' f'Class to describe: {class_value}' + (f' ({existing.title}). {existing.description}' if existing else '') + '\n\nList 3 to 6 key visual indicators that distinguish this class from ' 'its neighbours in this kind of imagery. Each indicator is one short, ' 'concrete sentence about something visible in the image. Return ONLY a ' 'JSON array of strings.' ) reply = self._ask(prompt, json_mode=True) data = _extract_json(reply, want_list=True) if isinstance(data, dict): data = next((v for v in data.values() if isinstance(v, list)), None) if not isinstance(data, list) or not data: raise ValueError('The model did not return a list of indicators.') return _clean_lines([str(i) for i in data])
[docs] def import_prompt(self, prompt_text: str) -> PromptSpec: """ Split an existing free-form prompt into a specification. Args: prompt_text (str): Any prompt, in the sample layout or not. Returns: PromptSpec: The extracted specification. Raises: ValueError: If the prompt is empty or the reply is unusable. """ if not prompt_text.strip(): raise ValueError('Paste or write a prompt to import first.') prompt = ( f'{_ASSISTANT_ROLE}\n\n' 'Convert the prompt below into a specification, preserving its wording ' 'wherever possible: task, persona, scope, steps, edge cases, every output ' 'field with its options, and every class description with its ' 'indicators. Return ONLY a JSON object with this schema:\n' f'{_SPEC_SCHEMA}\n\nPROMPT:\n{prompt_text.strip()}' ) return self._spec_from_reply(self._ask(prompt, json_mode=True))
# ------------------------------------------------------------ plumbing def _ask(self, prompt: str, json_mode: bool) -> str: """Send a text-only request and return the reply text (``''`` on failure).""" output = self.model.run_inference( [], prompt, temperature=self.temperature, max_tokens=self.max_tokens, json_mode=json_mode, ) text = getattr(output, 'text', None) if output is not None else None if not text or not text.strip(): raise ValueError( 'The model returned no text. Check the API key, model ID and the ' 'log for the provider error.' ) return text @staticmethod def _spec_from_reply(reply: str) -> PromptSpec: """Parse a JSON specification out of a model reply.""" data = _extract_json(reply) if not isinstance(data, dict): raise ValueError( 'The model did not return a JSON specification. Try again or pick a ' 'larger model.' ) return PromptSpec.from_dict(data)
def _apply_context(spec: PromptSpec, context: dict[str, Any]) -> PromptSpec: """Force GUI-known facts (marking, imagery, JSON mode) onto a drafted spec.""" if context.get('marking'): spec.marking = str(context['marking']) if context.get('imagery') in IMAGERY_KINDS: spec.imagery = str(context['imagery']) if 'json_output' in context: spec.json_output = bool(context['json_output']) if context.get('asset') and not spec.asset: spec.asset = str(context['asset']) return spec def _extract_json(text: str, want_list: bool = False) -> Any: """ Pull the first JSON object (or array) out of a model reply. Handles ```json fences, leading prose and trailing commentary. Returns ``None`` when nothing parses. """ if not text: return None candidates: list[str] = [] fenced = re.findall(r'```(?:json)?\s*(.*?)```', text, re.DOTALL) candidates.extend(fenced) candidates.append(text) open_, close = ('[', ']') if want_list else ('{', '}') for cand in candidates: cand = cand.strip() for attempt in ( cand, _bracketed(cand, open_, close), _bracketed(cand, '{', '}'), ): if not attempt: continue try: return json.loads(attempt) except json.JSONDecodeError: continue return None def _bracketed(text: str, open_: str, close: str) -> str: """Return the substring from the first ``open_`` to the last ``close``.""" start, end = text.find(open_), text.rfind(close) return text[start : end + 1] if 0 <= start < end else '' __all__ = [ 'ASSIST_ACTIONS', 'FIELD_KINDS', 'IMAGERY_KINDS', 'OutputField', 'PromptAssistant', 'PromptSpec', 'RubricEntry', 'SAMPLE_SPEC', 'assemble_prompt', 'describe_marking', 'snake_case', ]