39227-vm/scripts/ai_prepare_responses.py
2026-02-10 19:49:29 +01:00

269 lines
9.3 KiB
Python
Executable File

#!/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", "")),
"short_response": _ea_sanitize_text(p.get("short_response", "")),
"medium_response": _ea_sanitize_text(p.get("medium_response", "")),
}
)
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"},
"short_response": {"type": "string"},
"medium_response": {"type": "string"},
},
"required": ["index", "post_text", "short_response", "medium_response"],
},
},
"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 "")
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": {
"short_response": "1-2 sentences, direct, useful, no fluff.",
"medium_response": "3-6 sentences, more context, still concise.",
"style": "Follow the system instructions for voice/tone. 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 two reply options per post.\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"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:]))