#!/usr/bin/env python3 import argparse import base64 import json import os import re import sys from pathlib import Path def _load_dotenv_if_present(project_root: Path) -> None: """ Minimal .env loader: - supports KEY=VALUE - ignores blank lines and lines starting with '#' - does not support quotes/escapes; keep it simple """ if os.getenv("OPENAI_API_KEY"): return p = project_root / ".env" if not p.exists(): return try: for line in p.read_text("utf-8").splitlines(): s = line.strip() if not s or s.startswith("#") or "=" not in s: continue k, v = s.split("=", 1) k = k.strip() v = v.strip() if k and v and k not in os.environ: os.environ[k] = v except Exception: return def _read_json(path: Path) -> dict: return json.loads(path.read_text("utf-8")) def _data_url_for_png(png_path: Path) -> str: b64 = base64.b64encode(png_path.read_bytes()).decode("ascii") return f"data:image/png;base64,{b64}" def _truncate_text(s: str, max_chars: int) -> str: if len(s) <= max_chars: return s return s[: max_chars - 1] + "\u2026" def _safe_json_dump(obj: object, max_chars: int) -> str: s = json.dumps(obj, ensure_ascii=True, separators=(",", ":"), sort_keys=False) return _truncate_text(s, max_chars) def _ea_sanitize_text(text: object) -> str: if text is None: return "" s = str(text) if s == "": return "" s = s.replace("\r", "").replace("\t", " ") replacements = { "\u201c": '"', "\u201d": '"', "\u201e": '"', "\u201f": '"', "\u2018": "'", "\u2019": "'", "\u201a": "'", "\u201b": "'", "\u2014": "-", "\u2013": "-", "\u2212": "-", "\u2022": "- ", "\u2026": "...", } for k, v in replacements.items(): s = s.replace(k, v) s = re.sub( r"[\u00A0\u2000-\u200A\u202F\u205F\u3000\u1680\u180E\u2800\u3164\uFFA0]", " ", s, ) s = s.replace("\u2028", "\n").replace("\u2029", "\n\n") s = re.sub( r"[\u200B\u200C\u200D\u200E\u200F\u202A-\u202E\u2060\u2061\u2066-\u2069\u206A-\u206F\u00AD\u034F\u115F\u1160\u17B4\u17B5\u180B-\u180D\uFE00-\uFE0F\uFEFF\u001C\u000C]", "", s, ) s = s.replace("\u2062", "x").replace("\u2063", ",").replace("\u2064", "+") s = re.sub(r"[ ]{2,}", " ", s) s = re.sub(r"\n{3,}", "\n\n", s) return s.lower() def _sanitize_ai_payload(ai: dict, page_url: str, page_title: str) -> dict: out = dict(ai) if isinstance(ai, dict) else {} out["page_url"] = page_url out["page_title"] = page_title out["notes"] = _ea_sanitize_text(out.get("notes", "")) posts = out.get("posts", []) if not isinstance(posts, list): posts = [] cleaned_posts = [] for i, p in enumerate(posts): if not isinstance(p, dict): continue cleaned_posts.append( { "index": int(p.get("index", i)), "post_text": _ea_sanitize_text(p.get("post_text", "")), "improved_short": _ea_sanitize_text(p.get("improved_short", "")), "improved_medium": _ea_sanitize_text(p.get("improved_medium", "")), "critical_short": _ea_sanitize_text(p.get("critical_short", "")), "critical_medium": _ea_sanitize_text(p.get("critical_medium", "")), "suggested_short": _ea_sanitize_text(p.get("suggested_short", "")), "suggested_medium": _ea_sanitize_text(p.get("suggested_medium", "")), } ) out["posts"] = cleaned_posts return out def _response_schema(max_posts: int) -> dict: # Keep schema simple; strict mode supports a subset of JSON Schema. return { "type": "object", "additionalProperties": False, "properties": { "page_url": {"type": "string"}, "page_title": {"type": "string"}, "posts": { "type": "array", "maxItems": max_posts, "items": { "type": "object", "additionalProperties": False, "properties": { "index": {"type": "integer"}, "post_text": {"type": "string"}, "improved_short": {"type": "string"}, "improved_medium": {"type": "string"}, "critical_short": {"type": "string"}, "critical_medium": {"type": "string"}, "suggested_short": {"type": "string"}, "suggested_medium": {"type": "string"}, }, "required": [ "index", "post_text", "improved_short", "improved_medium", "critical_short", "critical_medium", "suggested_short", "suggested_medium", ], }, }, "notes": {"type": "string"}, }, # OpenAI strict json_schema currently expects all top-level properties to be required. "required": ["page_url", "page_title", "posts", "notes"], } def main(argv: list[str]) -> int: p = argparse.ArgumentParser( description="Use OpenAI to draft short + medium responses per visible post on the page (screenshot + extracted content)." ) p.add_argument("png_path", help="Path to saved screenshot PNG") p.add_argument("meta_path", help="Path to saved meta JSON") p.add_argument("content_path", nargs="?", default="", help="Optional path to saved extracted content JSON") p.add_argument("--model", default=os.getenv("AI_EA_MODEL", "gpt-5.2")) p.add_argument("--max-posts", type=int, default=int(os.getenv("AI_EA_MAX_POSTS", "12"))) p.add_argument("--out", default="", help="Output path (default: alongside PNG, with .ai.json suffix)") p.add_argument("--content-max-chars", type=int, default=120_000, help="Max chars of content JSON sent to the model") p.add_argument("--image-detail", default="auto", choices=["low", "high", "auto"]) args = p.parse_args(argv) project_root = Path(__file__).resolve().parents[1] _load_dotenv_if_present(project_root) try: from openai import OpenAI # type: ignore except Exception: print("Missing dependency: pip install openai", file=sys.stderr) return 2 if not os.getenv("OPENAI_API_KEY"): print("OPENAI_API_KEY is not set (export it or put it in .env). Skipping.", file=sys.stderr) return 3 png_path = Path(args.png_path).expanduser().resolve() meta_path = Path(args.meta_path).expanduser().resolve() content_path = Path(args.content_path).expanduser().resolve() if args.content_path else None meta = _read_json(meta_path) content = _read_json(content_path) if (content_path and content_path.exists()) else None instructions_path = project_root / "AI_EA_INSTRUCTIONS.MD" system_instructions = instructions_path.read_text("utf-8") if instructions_path.exists() else "" page_url = str(meta.get("url") or "") page_title = str(meta.get("title") or "") extra_instructions = str(meta.get("extra_instructions") or "").strip() user_payload = { "page_url": page_url, "page_title": page_title, "meta": meta, "content": content, "task": { "goal": "Draft replies to each distinct post currently visible on the page.", "definition_of_post": "A single feed item / post / story / comment root visible on-screen right now. If it's a single-article page, treat the main article as one post.", "output_requirements": { #"ironic": "Lightly ironic, laughing at us humans (not cruel).", "improved": "Proofread whatever is given in extra_instructions [EXTRA_INSTRUCTIONS]!!! Proofreading-style improvements, preserving the original words as much as possible. Improving in medium version.", "critical": "Bold/critical: politely questions the premise or assumptions.", "suggested": "Best style you think fits (helpful/witty/clarifying/etc).", "short": "direct, useful, no fluff. if X(twitter): 1-2 sentences max, if Reddit: 3-6 sentences max.", "medium": "more context, still concise. if X(twitter): 3-6 sentences max, if Reddit: 6-12 sentences max.", "style": "Follow the system instructions for voice/tone. Apply EXTRA_INSTRUCTIONS to all responses. If unclear what the post says, be honest and ask a question instead of guessing.", }, }, } prompt_text = ( "You will receive (1) a screenshot of the current viewport and (2) extracted visible page content.\n" "Identify each distinct post visible on the page and draft SIX reply options per post:\n" "- improved_short, improved_medium\n" "- critical_short, critical_medium\n" "- suggested_short, suggested_medium\n" "All six must follow the system instructions and EXTRA_INSTRUCTIONS.\n" "Do not invent facts not present in the screenshot/content.\n" "Return JSON matching the provided schema. Include all top-level keys: page_url, page_title, posts, notes.\n" "If a value is unknown, use an empty string.\n\n" + (f"EXTRA_INSTRUCTIONS={extra_instructions}\n\n" if extra_instructions else "") + f"PAGE_DATA_JSON={_safe_json_dump(user_payload, args.content_max_chars)}" ) # Screenshot is provided as a base64 data URL image input. image_data_url = _data_url_for_png(png_path) client = OpenAI() resp = client.responses.create( model=args.model, instructions=system_instructions, input=[ { "role": "user", "content": [ {"type": "input_text", "text": prompt_text}, {"type": "input_image", "image_url": image_data_url, "detail": args.image_detail}, ], } ], text={ "format": { "type": "json_schema", "name": "ea_post_responses", "description": "Draft short and medium replies for each visible post on the page.", "schema": _response_schema(args.max_posts), "strict": True, }, "verbosity": "low", }, max_output_tokens=1400, ) raw = resp.output_text or "" try: parsed = json.loads(raw) except Exception: parsed = {"error": "non_json_output", "raw": raw} if isinstance(parsed, dict) and "posts" in parsed: parsed = _sanitize_ai_payload(parsed, page_url=page_url, page_title=page_title) out_path = Path(args.out) if args.out else png_path.with_suffix(".ai.json") out_path.write_text(json.dumps(parsed, indent=2, ensure_ascii=True) + "\n", encoding="utf-8") print(str(out_path)) return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))