$ cat node-template.py

T

Text Transform

// Transform a text using a custom prompt via an LLM, or answer a question about an image (image Q&A) with a vision model: e.g. read the room labels on a floor plan or describe a photo. Input text and/or an image plus instructions; output the elaborated text, optionally parsed as JSON.

Process
LLM
#llm#text#transform#image#vision#image-qa#json
template.py
1"""2Text Transform34Two modes, picked by what is connected:5- text mode (no image): apply the prompt to `input_text` and return the6  elaborated text. The messages sent in this mode are a regression contract7  (R-3): they must stay byte-identical to 1.3.0.8- image mode (`image` connected): answer the prompt about the image with a9  vision model ("answer a question about an image"). `input_text`, if also10  given, is appended as context. The image is bounded before it is sent:11  long edge <= 2048 px, encoded size <= 4 MB (eng D3 = A).1213`output_format: json` (both modes) strips code fences, parses the reply and14returns it normalized in `output_text`; an unparseable reply fails the node15with the first 2000 characters of the raw reply.16"""1718from __future__ import annotations1920import asyncio21import base6422import io23import json24import mimetypes25import re26import sys27import traceback28from typing import Any2930from gais import Gais3132INPUT_DIR = "/data/input"3334SYSTEM_PROMPT = (35    "You are a careful text-transformation assistant. "36    "Apply the user's instructions to the provided text and return ONLY the "37    "transformed text — no preamble, no commentary, no surrounding quotes."38)3940IMAGE_SYSTEM_PROMPT = (41    "Answer the instruction about the attached image. "42    "If asked for JSON, return only JSON."43)4445# Image size bound for image mode (eng D3 = A).46MAX_IMAGE_EDGE = 204847MAX_IMAGE_BYTES = 4 * 1024 * 102448JPEG_QUALITY = 9249FALLBACK_JPEG_QUALITY = 8550_JPEG_FORMATS = ("JPEG", "MPO")51# Pillow refuses to decode images above ~179 megapixels (decompression-bomb52# guard). Say so instead of blaming the format.53_TOO_MANY_PIXELS_ERROR = "Image is too large to read (over ~179 megapixels); downscale it and try again"5455JSON_ERROR_EXCERPT_CHARS = 20005657OUTPUT_FORMATS = ("text", "json")5859# Explicit map first: the stdlib mimetypes table varies by platform and misses60# some image types (e.g. .webp on some builds).61_IMAGE_MIME_BY_EXT = {62    "jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png",63    "gif": "image/gif", "webp": "image/webp", "bmp": "image/bmp",64    "tif": "image/tiff", "tiff": "image/tiff", "heic": "image/heic",65    "heif": "image/heif", "avif": "image/avif", "svg": "image/svg+xml",66}6768# Output-language directive (language-persistence workstream). Values are69# platform locale codes; legacy full names are accepted via the allowlist.70# Anything else (wired upstream output, free-form strings) falls back to71# auto: the value is interpolated into the system prompt, so unknown72# strings must never pass through.73LANGUAGE_NAMES = {74    "de": "German", "en": "English", "es": "Spanish", "fr": "French",75    "it": "Italian", "ja": "Japanese", "ko": "Korean", "nl": "Dutch",76    "pt": "Portuguese", "ru": "Russian", "zh": "Chinese",77}787980def resolve_language_name(language):81    """Full display name for an allowlisted language value, else None."""82    if not isinstance(language, str):83        return None84    value = language.strip()85    if not value or value == "auto":86        return None87    if value in LANGUAGE_NAMES:88        return LANGUAGE_NAMES[value]89    if value in LANGUAGE_NAMES.values():90        return value91    print(f"[language] unrecognized value {value!r} ignored - using auto", file=sys.stderr)92    return None939495# ── messages ────────────────────────────────────────────────────────────────969798def build_text_messages(input_text: str, prompt: str, language: Any = "auto") -> list[dict[str, Any]]:99    """Text-mode messages. Regression contract R-3: keep byte-identical."""100    system_prompt = SYSTEM_PROMPT101    lang_name = resolve_language_name(language)102    if lang_name:103        system_prompt += (104            f"\n\nOUTPUT LANGUAGE: {lang_name}. Write the transformed text in "105            f"{lang_name} regardless of the source text's language. This "106            "OVERRIDES any language stated in the instructions or implied by "107            "the source text. Keep proper nouns and verbatim quotes in their "108            "original language."109        )110    user_message = (111        f"<instructions>{prompt}</instructions>\n\n"112        f"<text>{input_text}</text>"113    )114    return [115        {"role": "system", "content": system_prompt},116        {"role": "user", "content": user_message},117    ]118119120def build_image_messages(121    prompt: str, input_text: str, data_url: str, language: Any = "auto",122) -> list[dict[str, Any]]:123    """Image-mode messages: the prompt (+ optional text context) and the image."""124    system_prompt = IMAGE_SYSTEM_PROMPT125    lang_name = resolve_language_name(language)126    if lang_name:127        system_prompt += (128            f"\n\nOUTPUT LANGUAGE: {lang_name}. Write the answer in {lang_name}. "129            "Keep JSON keys, proper nouns and text quoted from the image in "130            "their original form."131        )132    text = prompt + (f"\n\n<text>{input_text}</text>" if input_text else "")133    return [134        {"role": "system", "content": system_prompt},135        {136            "role": "user",137            "content": [138                {"type": "text", "text": text},139                {"type": "image_url", "image_url": {"url": data_url}},140            ],141        },142    ]143144145# ── image input ─────────────────────────────────────────────────────────────146147148def _image_file_name(raw: Any) -> str | None:149    """Upstream file ports deliver a bare filename under /data/input."""150    if isinstance(raw, list):151        items = [item for item in raw if item]152        if len(items) > 1:153            raise ValueError(154                f"Text Transform accepts a single image, got {len(items)}. "155                "Use one Text Transform per image."156            )157        raw = items[0] if items else None158    if not raw or not isinstance(raw, str):159        return None160    base = raw.replace("\\", "/").rsplit("/", 1)[-1].strip()161    if not base or base in (".", ".."):162        return None163    return base164165166def _guess_mime(name: str) -> str:167    ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""168    return (169        _IMAGE_MIME_BY_EXT.get(ext)170        or mimetypes.guess_type(name)[0]171        or "application/octet-stream"172    )173174175def _bounded_size(width: int, height: int) -> tuple[int, int]:176    """Scale so the long edge is <= MAX_IMAGE_EDGE, keeping the aspect ratio."""177    long_edge = max(width, height)178    if long_edge <= MAX_IMAGE_EDGE:179        return width, height180    if width >= height:181        return MAX_IMAGE_EDGE, max(1, round(height * MAX_IMAGE_EDGE / width))182    return max(1, round(width * MAX_IMAGE_EDGE / height)), MAX_IMAGE_EDGE183184185def _to_rgb(img: Any) -> Any:186    """JPEG has no alpha: composite transparent images onto white."""187    if img.mode in ("RGB", "L"):188        return img189    if img.mode in ("RGBA", "LA"):190        from PIL import Image191192        rgba = img.convert("RGBA")193        background = Image.new("RGB", rgba.size, (255, 255, 255))194        background.paste(rgba, mask=rgba.getchannel("A"))195        return background196    return img.convert("RGB")197198199def _encode(img: Any, fmt: str, quality: int = JPEG_QUALITY) -> bytes:200    buf = io.BytesIO()201    if fmt == "JPEG":202        _to_rgb(img).save(buf, format="JPEG", quality=quality)203    else:204        img.save(buf, format="PNG")205    return buf.getvalue()206207208def load_image_as_data_url(path: str, mime: str) -> tuple[str, dict[str, Any]]:209    """Open, orient, bound and encode the image; return (data URL, stats).210211    PNG stays PNG and JPEG stays JPEG; other formats are sent as PNG. If the212    encoded result is still above MAX_IMAGE_BYTES it is re-encoded as JPEG213    q85. Re-encoding also applies the EXIF orientation and drops all EXIF214    (including GPS) before the image leaves the executor.215    """216    from PIL import Image, ImageOps217218    try:219        img = Image.open(path)220    except FileNotFoundError:221        raise ValueError(f"Input image not found: {path.rsplit('/', 1)[-1]}") from None222    except Image.DecompressionBombError:223        raise ValueError(_TOO_MANY_PIXELS_ERROR) from None224    except Exception as e:225        # gstack-shortcut(dec-bf78833f): HEIC needs manual conversion, upgrade when the first real HEIC upload arrives226        raise ValueError(f"Can't read image ({mime}); convert to JPG or PNG") from e227228    try:229        source_format = img.format230        source_size = img.size231        # Pillow opens many phone JPEGs (an MPF marker) as MPO, a JPEG232        # subclass: same reduced decode, same JPEG output.233        is_jpeg = source_format in _JPEG_FORMATS234        if is_jpeg:235            # Decode at a reduced DCT scale (1/2, 1/4, 1/8) that is still at236            # least the target size: a 50 MP photo never fully decodes.237            img.draft(img.mode, _bounded_size(*img.size))238        img.load()239        # Apply EXIF orientation: the re-encoded copy carries no EXIF, so a240        # rotated phone photo would otherwise reach the model sideways.241        ImageOps.exif_transpose(img, in_place=True)242        if img.mode.startswith("I;16"):243            img = img.convert("I")244        if img.mode == "I":245            # 16-bit samples: scale to 8 bits first; a plain convert clips246            # everything above 255, and the model would see a white image.247            img = img.point(lambda v: v * (1 / 256)).convert("L")248        if img.mode not in ("L", "LA", "RGB", "RGBA"):249            has_alpha = "transparency" in img.info or any(b in ("A", "a") for b in img.getbands())250            img = img.convert("RGBA" if has_alpha else "RGB")251    except Image.DecompressionBombError:252        raise ValueError(_TOO_MANY_PIXELS_ERROR) from None253    except Exception as e:254        # gstack-shortcut(dec-bf78833f): HEIC needs manual conversion, upgrade when the first real HEIC upload arrives255        raise ValueError(f"Can't read image ({mime}); convert to JPG or PNG") from e256257    target = _bounded_size(*img.size)258    if target != img.size:259        img = img.resize(target, Image.Resampling.LANCZOS)260261    out_format = "JPEG" if is_jpeg else "PNG"262    data = _encode(img, out_format)263    if len(data) > MAX_IMAGE_BYTES:264        out_format = "JPEG"265        data = _encode(img, "JPEG", quality=FALLBACK_JPEG_QUALITY)266267    out_mime = "image/jpeg" if out_format == "JPEG" else "image/png"268    stats = {269        "source_format": source_format,270        "source_size": source_size,271        "sent_size": img.size,272        "sent_mime": out_mime,273        "sent_bytes": len(data),274    }275    data_url = f"data:{out_mime};base64,{base64.b64encode(data).decode('ascii')}"276    return data_url, stats277278279# ── output ──────────────────────────────────────────────────────────────────280281_FENCE = "```"282_FENCE_LANG_RE = re.compile(r"[\w+-]*")283284285def _strip_code_fences(raw: str) -> str:286    """The body of a reply wrapped in one ``` fence, else of its first fenced287    block, else the reply. Line-based on purpose: a regex over the whole reply288    backtracks quadratically on a long run of spaces."""289    text = raw.strip()290    if len(text) >= 2 * len(_FENCE) and text.startswith(_FENCE) and text.endswith(_FENCE):291        body = text[len(_FENCE):-len(_FENCE)]292        head, newline, rest = body.partition("\n")293        if newline and _FENCE_LANG_RE.fullmatch(head.strip()):294            return rest.strip()295        return body.strip()296    lines = text.split("\n")297    for start, line in enumerate(lines):298        at = line.find(_FENCE)299        if at == -1 or not _FENCE_LANG_RE.fullmatch(line[at + len(_FENCE):].strip()):300            continue301        for end in range(start + 1, len(lines)):302            if lines[end].lstrip().startswith(_FENCE):303                return "\n".join(lines[start + 1:end]).strip()304        break305    return text306307308def _reject_non_json_constant(name: str) -> Any:309    raise ValueError(f"{name} is not valid JSON")310311312def normalize_json_reply(raw: str) -> str:313    """Parse the model reply as JSON and return it re-serialized."""314    try:315        obj = json.loads(_strip_code_fences(raw), parse_constant=_reject_non_json_constant)316        # allow_nan=False also refuses 1e999, which loads as float("inf").317        return json.dumps(obj, ensure_ascii=False, allow_nan=False)318    except ValueError:319        raise ValueError(320            "Model did not return valid JSON: " + raw[:JSON_ERROR_EXCERPT_CHARS]321        ) from None322323324def _output_format(raw: Any) -> str:325    if raw is None or raw == "":326        return "text"327    value = str(raw).strip().lower()328    if value not in OUTPUT_FORMATS:329        raise ValueError(f"output_format must be 'text' or 'json', got {raw!r}")330    return value331332333# ── run ─────────────────────────────────────────────────────────────────────334335336async def _chat(messages: list[dict[str, Any]], model_id: str) -> str:337    response = await Gais.llm.chat_async(messages, model_id=model_id, temperature=0, thinking=False)338    return response.text.strip()339340341def process(inputs: dict[str, Any]) -> dict[str, Any]:342    input_text = inputs.get("input_text")343    prompt = inputs.get("prompt")344    llm_model_id = inputs.get("llmModelId")345    language = inputs.get("language", "auto")346    output_format = _output_format(inputs.get("output_format"))347    image_name = _image_file_name(inputs.get("image"))348349    if not input_text and not image_name:350        raise ValueError(351            "Required input missing: provide 'input_text' or connect an 'image'"352        )353    if not prompt:354        raise ValueError("Required input 'prompt' not provided")355    if not llm_model_id:356        raise ValueError("Required input 'llmModelId' not provided")357358    if image_name:359        mime = _guess_mime(image_name)360        data_url, stats = load_image_as_data_url(f"{INPUT_DIR}/{image_name}", mime)361        print(362            f"[text-transform] mode=image model_id={llm_model_id} "363            f"source={stats['source_format']} {stats['source_size'][0]}x{stats['source_size'][1]} "364            f"sent={stats['sent_mime']} {stats['sent_size'][0]}x{stats['sent_size'][1]} "365            f"bytes={stats['sent_bytes']} context_chars={len(input_text or '')}",366            file=sys.stderr,367        )368        if stats["sent_bytes"] > MAX_IMAGE_BYTES:369            print(370                f"[text-transform] image still {stats['sent_bytes']} bytes after JPEG "371                f"q{FALLBACK_JPEG_QUALITY}; sending anyway",372                file=sys.stderr,373            )374        messages = build_image_messages(prompt, input_text or "", data_url, language)375    else:376        print(377            f"[text-transform] mode=text model_id={llm_model_id} input_chars={len(input_text)}",378            file=sys.stderr,379        )380        messages = build_text_messages(input_text, prompt, language)381382    output_text = asyncio.run(_chat(messages, llm_model_id))383    if output_format == "json":384        output_text = normalize_json_reply(output_text)385386    print(387        f"[text-transform] done output_format={output_format} output_chars={len(output_text)}",388        file=sys.stderr,389    )390    return {"output_text": output_text}391392393def main() -> None:394    try:395        envelope = json.loads(sys.stdin.read() or "{}")396        inputs: dict[str, Any] = envelope.get("inputs", {}) if isinstance(envelope, dict) else {}397        json.dump(process(inputs or {}), sys.stdout)398    except Exception as e:399        error = {400            "error": str(e),401            "errorType": type(e).__name__,402            "traceback": traceback.format_exc(),403        }404        print(json.dumps(error), file=sys.stderr)405        sys.exit(1)406407408if __name__ == "__main__":409    main()410

$ git log --oneline

v1.4.0
HEAD
2026-10-10

Image mode: connect an image and ask about it (long edge 2048 px, 4 MB, EXIF and GPS stripped); JSON output format

v1.3.02026-08-18