#!/usr/bin/env python3
"""Image prompt doctor: review a prompt, flag missing elements, return a rewritten version.
Calls a text model through APIYI (default gpt-5.6-luna). Standard library only, no dependencies.
Two modes:
1) pre-generation diagnosis -- prompt only
2) post-generation review -- prompt plus the actual result, so the model can read it back
"""
import argparse
import base64
import json
import os
import sys
import urllib.error
import urllib.request
DEFAULT_MODEL = "gpt-5.6-luna"
BASE_URL = "https://api.apiyi.com/v1/chat/completions"
MAX_IMAGES = 4
# Extra notes per target family, appended only when --target is given
TARGET_NOTES = {
"nano-banana": "Target is Nano Banana (Gemini family): long natural-language sentences work well, "
"so write flowing paragraphs rather than keyword piles; up to 14 reference images; "
"resolution goes in imageSize (1K/2K/4K) and aspect ratio in aspectRatio.",
"gpt-image": "Target is the GPT-Image family: strong instruction following and accurate in-image text, "
"so specifying exact text is safe; up to 16 reference images; only the official-relay "
"gpt-image-2 supports mask inpainting; resolution goes in size.",
"seedream": "Target is Seedream: strong with Chinese-language briefs; up to 10 reference images "
"(inputs plus outputs must stay at or below 15); 5.0 and 5.0-pro can be prompted to "
"return a PNG with a transparent background.",
"flux": "Target is FLUX: prefers clearly structured description; FLUX.2 pro/max/flex take up to 8 "
"reference images, Kontext takes 1.",
"grok": "Target is Grok Imagine: reference images only take effect on /v1/images/edits — passing them "
"to /v1/images/generations silently discards them and still bills; up to 4 reference images.",
}
SCENE_NOTES = {
"portrait": "This is a portrait. Check especially: is there one key light with a stated direction and "
"hardness, are focal length and aperture given, is natural skin requested (pores, fuzz, "
"shine), is retouching disabled, is the subject moved off dead centre.",
"product": "This is a product or e-commerce shot. Check especially: is the background specified as "
"neutral and controllable, are key light and fill written out, is the shadow direction given, "
"is text and branding explicitly forbidden (otherwise the model invents them), is negative "
"space left for copy.",
"scene": "This is an environment. Check especially: specific time and weather, one identifiable key "
"light, camera height and focal length, whether wear and clutter were added for realism, "
"whether people are asked not to face the camera.",
"illustration": "This is illustration, not photorealism. Down-weight the realism checklist and instead "
"check: is the style named specifically (medium, brushwork, era, school), the palette, "
"the line and colouring method, composition and negative space.",
}
SYSTEM = """You are an image prompt diagnostician serving developers and designers who call image
models directly over an API. An API call is a single atomic call: the prompt reaches the model
verbatim, with none of the automatic rewriting a web app does, so prompt quality decides the hit rate.
## Rubric
First mark each of the six elements ok (clearly stated) / weak (mentioned but vague) / missing:
1 subject: are material, colour, count and state specific
2 environment: what the background is, what is sharp and what is blurred
3 light: direction, hardness, fill -- there must be one identifiable key light
4 lens: focal length, aperture, camera height, tilt
5 tone: white balance bias, saturation, film or digital character
6 composition: where the subject sits in the frame, where the negative space is
## Risks you must report
- Vague quality words such as 8K / ultra HD / ultra detailed / masterpiece / perfect: they add no
resolution and push the frame toward an over-sharpened, oversaturated render, which is the core of
the AI look. Always recommend deleting them in favour of specific light, lens and medium.
- Resolution written into the prompt (4K/8K/high definition): no effect. Resolution comes only from
parameters such as size / imageSize.
- Several unrelated edits crammed into one sentence: the single-shot hit rate drops sharply; split
into rounds.
- Pronouns such as "this" or "the thing in the red box" instead of naming the object: the most
common failure in editing tasks.
- No statement about text in the image: the model may invent brand names or copy, which makes the
frame commercially unusable. Either state what text should appear, or forbid text explicitly.
- Real people, celebrities, copyrighted characters, minors, violence or adult content: these get
blocked upstream and need rewriting first.
## Rewriting principles
- Fill in what is missing; do not pad the prompt with adjectives to make it longer.
- Leave anything the user specified exactly as written.
- For realism, add specific light positions, focal length and aperture, a film or digital medium, and
deliberate imperfections (pores, stray hair, wear, water rings) rather than abstractions such as
"realistic" or "premium".
- Do not emit negative-prompt syntax (most image models have no separate negative prompt field);
write what to avoid into the prompt body.
- optimized_prompt and changes must use the same language as the user's original prompt.
## Output
Emit exactly this JSON, with no code fence and no extra commentary:
{
"score": integer 0-100 for how usable this prompt is in a single shot,
"verdict": "one-line summary, at most 20 words",
"elements": {"subject":"ok|weak|missing","environment":"...","light":"...","lens":"...","tone":"...","composition":"..."},
"risks": ["one sentence each, stating the problem and its consequence; empty array if none"],
"optimized_prompt": "the full rewritten prompt, ready to use as-is",
"changes": ["what changed and why, one per entry"],
"suggested_params": {"size":"1K|2K|4K","aspect":"e.g. 1:1 / 16:9","note":"parameter advice, empty string if none"}
}"""
REVIEW_EXTRA = """
## This run is a post-generation review
The user already generated an image from the prompt below; the actual result is attached. Check it
against the prompt line by line: what was honoured, what was not, and what the model added on its
own. In risks, state explicitly which sentence of the prompt was not executed, and rewrite
optimized_prompt to target those deviations rather than generically filling in elements."""
def load_api_key():
"""Prefer the environment variable; otherwise look for .env beside the script or one level up."""
key = os.environ.get("APIYI_API_KEY")
if key:
return key
here = os.path.dirname(os.path.abspath(__file__))
for d in (here, os.path.dirname(here)):
env_path = os.path.join(d, ".env")
if os.path.exists(env_path):
with open(env_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line.startswith("APIYI_API_KEY") and "=" in line:
return line.split("=", 1)[1].strip().strip('"').strip("'")
return None
def image_data_url(path):
mime = "image/png" if path.lower().endswith(".png") else "image/jpeg"
with open(path, "rb") as f:
return f"data:{mime};base64," + base64.b64encode(f.read()).decode()
def build_messages(prompt, images, target, scene):
system = SYSTEM
if images:
system += REVIEW_EXTRA
extras = [TARGET_NOTES[target]] if target else []
if scene and scene != "auto":
extras.append(SCENE_NOTES[scene])
if extras:
system += "\n\n## Extra constraints for this run\n\n" + "\n".join("- " + e for e in extras)
content = [{"type": "text", "text": "Prompt to diagnose:\n\n" + prompt}]
for path in images:
content.append({"type": "image_url", "image_url": {"url": image_data_url(path)}})
return [{"role": "system", "content": system},
{"role": "user", "content": content}]
def diagnose(api_key, model, messages):
payload = json.dumps({
"model": model,
"messages": messages,
"response_format": {"type": "json_object"},
}).encode()
req = urllib.request.Request(
BASE_URL, data=payload, method="POST",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=180) as r:
resp = json.loads(r.read())
except urllib.error.HTTPError as e:
raise RuntimeError(f"request failed HTTP {e.code}: {e.read().decode(errors='replace')[:500]}")
text = resp["choices"][0]["message"]["content"].strip()
if text.startswith("```"): # defensive: some models still wrap in a fence
text = text.split("\n", 1)[1].rsplit("```", 1)[0]
try:
return json.loads(text)
except json.JSONDecodeError:
raise RuntimeError("model did not return valid JSON, raw output:\n" + text[:800])
MARK = {"ok": "OK", "weak": "WEAK", "missing": "MISSING"}
LABEL = {"subject": "subject", "environment": "environment", "light": "light",
"lens": "lens", "tone": "grading", "composition": "composition"}
def render(r):
out = [f"[Diagnosis] {r.get('score', '?')}/100 - {r.get('verdict', '')}", ""]
els = r.get("elements", {})
out.append("Six elements: " + " ".join(
f"{LABEL.get(k, k)} {MARK.get(v, '?')}" for k, v in els.items()))
risks = r.get("risks") or []
if risks:
out += ["", "Risks:"] + [f" - {x}" for x in risks]
else:
out += ["", "Risks: none"]
out += ["", "[Optimized prompt]", "", r.get("optimized_prompt", "")]
changes = r.get("changes") or []
if changes:
out += ["", "[What changed]"] + [f" - {x}" for x in changes]
p = r.get("suggested_params") or {}
bits = []
if p.get("size"):
bits.append(f"size={p['size']}")
if p.get("aspect"):
bits.append(f"aspect={p['aspect']}")
line = " ".join(bits)
if p.get("note"):
line = (line + "; " if line else "") + p["note"]
if line:
out += ["", "[Parameter suggestions] " + line]
return "\n".join(out)
def main():
parser = argparse.ArgumentParser(description="Diagnose and optimize an image prompt")
parser.add_argument("prompt", help="the prompt to diagnose")
parser.add_argument("-i", "--image", action="append", default=[],
help=f"path to an actual result, repeatable (up to {MAX_IMAGES}); switches on review mode")
parser.add_argument("-t", "--target", choices=sorted(TARGET_NOTES),
help="target image model, to append notes specific to that family")
parser.add_argument("-s", "--scene", choices=["auto"] + sorted(SCENE_NOTES), default="auto",
help="subject, default auto (no subject-specific checks appended)")
parser.add_argument("--model", default=os.environ.get("APIYI_TEXT_MODEL", DEFAULT_MODEL),
help=f"text model used for diagnosis, default {DEFAULT_MODEL}")
parser.add_argument("--json", action="store_true", help="emit raw JSON for programmatic use")
args = parser.parse_args()
api_key = load_api_key()
if not api_key:
sys.exit("no API key found: add a line APIYI_API_KEY=sk-xxx to .env in the skill folder, "
"or set the environment variable of the same name")
if len(args.image) > MAX_IMAGES:
sys.exit(f"at most {MAX_IMAGES} images, got {len(args.image)}")
for path in args.image:
if not os.path.exists(path):
sys.exit(f"image not found: {path}")
messages = build_messages(args.prompt, args.image, args.target, args.scene)
try:
result = diagnose(api_key, args.model, messages)
except RuntimeError as e:
sys.exit(str(e))
print(json.dumps(result, ensure_ascii=False, indent=2) if args.json else render(result))
if __name__ == "__main__":
main()