From beb64e2ed017430ef9ba033f38a4f7a6b3376f03 Mon Sep 17 00:00:00 2001 From: okendoken Date: Mon, 16 Feb 2026 23:00:15 +0100 Subject: [PATCH] add imrpvoed --- LOCAL_SCREENSHOT_EXTENSION.md | 1 + README.md | 8 + chrome_screenshot_ext/popup.js | 43 +++- scripts/ai_prepare_responses.py | 2 +- tools/local_screenshot_bridge.py | 358 ++++++++++++++++++++++++++++--- 5 files changed, 377 insertions(+), 35 deletions(-) diff --git a/LOCAL_SCREENSHOT_EXTENSION.md b/LOCAL_SCREENSHOT_EXTENSION.md index 8dca23a..fe42fa8 100644 --- a/LOCAL_SCREENSHOT_EXTENSION.md +++ b/LOCAL_SCREENSHOT_EXTENSION.md @@ -27,6 +27,7 @@ Notes: - The server listens on `http://127.0.0.1:8765/screenshot`. - If you omit `--ai`, it will only save files (no OpenAI call). - If you set `--ai`, it will generate reply suggestions and return them back to the extension (and also save `*.ai.json`). +- If you're debugging, add `--log-level debug` (or set `AIEA_LOG_LEVEL=debug`) for more detail. - Optional: you can still use `--run ...` as a post-save hook. ## 2) Load the extension (unpacked) diff --git a/README.md b/README.md index a2b68be..fa56c23 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,14 @@ Without OpenAI (save files only): python3 tools/local_screenshot_bridge.py --port 8765 --out-dir screenshots ``` +More logging (includes AI request/response ids + parse details): + +```bash +python3 tools/local_screenshot_bridge.py --port 8765 --out-dir screenshots --ai --log-level debug + +python3 tools/local_screenshot_bridge.py --port 8765 --out-dir screenshots --ai --log-level debug --ai-max-output-tokens 2500 +``` + Health check: ```bash diff --git a/chrome_screenshot_ext/popup.js b/chrome_screenshot_ext/popup.js index 855b058..efb6512 100644 --- a/chrome_screenshot_ext/popup.js +++ b/chrome_screenshot_ext/popup.js @@ -46,7 +46,14 @@ function renderResult(entryOrResp) { if (resp.ai_result) { if (resp.ai_result.ok && resp.ai_result.ai && Array.isArray(resp.ai_result.ai.posts)) { lines.push(""); - lines.push(`AI (${resp.ai_result.took_ms || "?"}ms):`); + { + const ms = resp.ai_result.took_ms || "?"; + const model = resp.ai_result.model ? ` model=${resp.ai_result.model}` : ""; + const rid = resp.ai_result.response_id ? ` id=${resp.ai_result.response_id}` : ""; + const status = resp.ai_result.status && resp.ai_result.status !== "completed" ? ` status=${resp.ai_result.status}` : ""; + const inc = resp.ai_result.incomplete_reason ? ` incomplete=${resp.ai_result.incomplete_reason}` : ""; + lines.push(`AI (${ms}ms${model}${rid}${status}${inc}):`); + } for (const p of resp.ai_result.ai.posts) { const idx = typeof p.index === "number" ? p.index : "?"; const postText = (p.post_text || "").replace(/\s+/g, " ").trim(); @@ -84,10 +91,40 @@ function renderResult(entryOrResp) { } } if (resp.ai_result.ai_path) lines.push(`\nAI file: ${resp.ai_result.ai_path}`); + if (resp.ai_result.usage && typeof resp.ai_result.usage === "object") { + const u = resp.ai_result.usage; + const ins = typeof u.input_tokens === "number" ? u.input_tokens : null; + const outs = typeof u.output_tokens === "number" ? u.output_tokens : null; + const tots = typeof u.total_tokens === "number" ? u.total_tokens : null; + const parts = []; + if (ins != null) parts.push(`in=${ins}`); + if (outs != null) parts.push(`out=${outs}`); + if (tots != null) parts.push(`total=${tots}`); + if (parts.length) lines.push(`AI usage: ${parts.join(" ")}`); + } } else { lines.push(""); - lines.push(`AI error: ${resp.ai_result.error || "unknown"}`); - if (resp.ai_result.detail) lines.push(`Detail: ${resp.ai_result.detail}`); + const ms = resp.ai_result.took_ms || "?"; + const model = resp.ai_result.model ? ` model=${resp.ai_result.model}` : ""; + const rid = resp.ai_result.response_id ? ` id=${resp.ai_result.response_id}` : ""; + const status = resp.ai_result.status ? ` status=${resp.ai_result.status}` : ""; + const inc = resp.ai_result.incomplete_reason ? ` incomplete=${resp.ai_result.incomplete_reason}` : ""; + const err = resp.ai_result.error || (resp.ai_result.ai && resp.ai_result.ai.error) || "unknown"; + lines.push(`AI error (${ms}ms${model}${rid}${status}${inc}): ${err}`); + if (resp.ai_result.detail) lines.push(`Detail: ${clampString(resp.ai_result.detail, 600)}`); + if (resp.ai_result.raw_preview) lines.push(`Raw: ${clampString(resp.ai_result.raw_preview, 600)}`); + if (resp.ai_result.usage && typeof resp.ai_result.usage === "object") { + const u = resp.ai_result.usage; + const ins = typeof u.input_tokens === "number" ? u.input_tokens : null; + const outs = typeof u.output_tokens === "number" ? u.output_tokens : null; + const tots = typeof u.total_tokens === "number" ? u.total_tokens : null; + const parts = []; + if (ins != null) parts.push(`in=${ins}`); + if (outs != null) parts.push(`out=${outs}`); + if (tots != null) parts.push(`total=${tots}`); + if (parts.length) lines.push(`AI usage: ${parts.join(" ")}`); + } + if (resp.ai_result.ai_path) lines.push(`AI file: ${resp.ai_result.ai_path}`); } } diff --git a/scripts/ai_prepare_responses.py b/scripts/ai_prepare_responses.py index d0e1c03..aa28223 100755 --- a/scripts/ai_prepare_responses.py +++ b/scripts/ai_prepare_responses.py @@ -222,7 +222,7 @@ def main(argv: list[str]) -> int: "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": "Proofreading-style improvements, preserving the original words as much as possible. Improving in medium version.", + "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": "1-2 sentences, direct, useful, no fluff.", diff --git a/tools/local_screenshot_bridge.py b/tools/local_screenshot_bridge.py index 3183e2e..2114761 100755 --- a/tools/local_screenshot_bridge.py +++ b/tools/local_screenshot_bridge.py @@ -7,9 +7,33 @@ import re import subprocess import sys import time +import traceback from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path +from typing import Optional + + +_LOG_LEVELS = {"debug": 10, "info": 20, "error": 40, "quiet": 100} + + +def _log(server, level: str, msg: str) -> None: + """ + Minimal structured-ish logging to stderr. + + server.log_level: debug|info|error|quiet (default: info) + """ + try: + configured = getattr(server, "log_level", "info") if server is not None else "info" + configured_rank = _LOG_LEVELS.get(str(configured).lower(), _LOG_LEVELS["info"]) + level_rank = _LOG_LEVELS.get(str(level).lower(), _LOG_LEVELS["info"]) + if level_rank < configured_rank: + return + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + print(f"[{ts}] {level.upper()}: {msg}", file=sys.stderr) + except Exception: + # Never let logging break the handler. + return def _slug(s: str, max_len: int = 80) -> str: @@ -58,6 +82,91 @@ def _safe_json_dump(obj: object, max_chars: int) -> str: s = json.dumps(obj, ensure_ascii=True, separators=(",", ":"), sort_keys=False) return _truncate(s, max_chars) + +def _maybe_dump_model(obj: object) -> Optional[dict]: + """ + Best-effort conversion of SDK model objects (pydantic-ish) to plain dicts. + """ + if obj is None: + return None + for attr in ("model_dump", "dict", "to_dict"): + fn = getattr(obj, attr, None) + if callable(fn): + try: + out = fn() + return out if isinstance(out, dict) else None + except Exception: + continue + return None + + +def _extract_json_text(raw: str) -> Optional[str]: + """ + Best-effort extraction of a JSON object from a text response. + + Some providers occasionally wrap JSON in code fences or add preamble text. + We keep this conservative; if it can't be extracted, we return None. + """ + if not raw: + return None + + # ```json ... ``` or any fenced block: ```...``` + # We capture the whole fenced body (not just {...}) so nested braces don't break extraction. + m = re.search(r"```(?:[a-z0-9_+-]+)?\s*(.*?)\s*```", raw, flags=re.DOTALL | re.IGNORECASE) + if m: + return m.group(1).strip() + + # First {...last} span + start = raw.find("{") + end = raw.rfind("}") + if start != -1 and end != -1 and end > start: + return raw[start : end + 1].strip() + + return None + + +def _ai_error_payload( + *, + error: str, + detail: str = "", + raw: str = "", + model: str = "", + response_id: str = "", + status: str = "", + incomplete_reason: str = "", + usage: Optional[dict] = None, + took_ms: Optional[int] = None, + ai_path: Optional[Path] = None, +) -> dict: + payload: dict = {"ok": False, "error": error} + if detail: + payload["detail"] = detail + if raw: + payload["raw_preview"] = _truncate(raw, 2000) + if model: + payload["model"] = model + if response_id: + payload["response_id"] = response_id + if status: + payload["status"] = status + if incomplete_reason: + payload["incomplete_reason"] = incomplete_reason + if usage: + payload["usage"] = usage + if isinstance(took_ms, int): + payload["took_ms"] = took_ms + if ai_path is not None: + payload["ai_path"] = str(ai_path) + return payload + + +def _is_valid_ai_json(parsed: object) -> bool: + if not isinstance(parsed, dict): + return False + posts = parsed.get("posts", None) + return isinstance(posts, list) + + def _ea_sanitize_text(text: object) -> str: """ Port of fl_geo_sanitize_text(), plus lowercase output (no capitals). @@ -209,11 +318,13 @@ def _maybe_generate_ai(server, png_path: Path, meta: dict, content: object) -> d _load_dotenv_if_present(project_root) if not os.getenv("OPENAI_API_KEY"): + _log(server, "error", "AI disabled: missing OPENAI_API_KEY") return {"ok": False, "error": "missing_openai_api_key"} try: from openai import OpenAI # type: ignore except Exception as e: + _log(server, "error", f"AI disabled: missing openai sdk ({type(e).__name__}: {e})") return {"ok": False, "error": "missing_openai_sdk", "detail": str(e)} instructions_text = getattr(server, "ai_instructions", "") @@ -227,6 +338,12 @@ def _maybe_generate_ai(server, png_path: Path, meta: dict, content: object) -> d page_title = str(meta.get("title") or "") extra_instructions = str(meta.get("extra_instructions") or "").strip() + _log( + server, + "debug", + f"AI request: model={model} max_posts={max_posts} image_detail={image_detail} max_output_tokens={max_output_tokens} url={_truncate(page_url, 140)}", + ) + user_payload = { "page_url": page_url, "page_title": page_title, @@ -237,7 +354,7 @@ def _maybe_generate_ai(server, png_path: Path, meta: dict, content: object) -> d "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": "Proofreading-style improvements, preserving the original words as much as possible. Improving in medium version.", + "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": "1-2 sentences, direct, useful, no fluff.", @@ -266,49 +383,196 @@ def _maybe_generate_ai(server, png_path: Path, meta: dict, content: object) -> d t0 = time.monotonic() client = OpenAI() - resp = client.responses.create( - model=model, - instructions=instructions_text, - input=[ - { - "role": "user", - "content": [ - {"type": "input_text", "text": prompt_text}, - {"type": "input_image", "image_url": image_data_url, "detail": 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(max_posts), - "strict": True, + resp = None + took_ms = None + try: + resp = client.responses.create( + model=model, + instructions=instructions_text, + input=[ + { + "role": "user", + "content": [ + {"type": "input_text", "text": prompt_text}, + {"type": "input_image", "image_url": image_data_url, "detail": 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(max_posts), + "strict": True, + }, + "verbosity": "low", }, - "verbosity": "low", - }, - max_output_tokens=max_output_tokens, - ) - took_ms = int((time.monotonic() - t0) * 1000) + max_output_tokens=max_output_tokens, + ) + took_ms = int((time.monotonic() - t0) * 1000) + except Exception as e: + took_ms = int((time.monotonic() - t0) * 1000) + tb = traceback.format_exc(limit=8) + detail = f"{type(e).__name__}: {e}" + # Some OpenAI exceptions have request id / status in attrs; include if present. + rid = getattr(e, "request_id", "") or getattr(getattr(e, "response", None), "headers", {}).get("x-request-id", "") + sc = getattr(e, "status_code", None) + if rid: + detail = f"{detail} (request_id={rid})" + if isinstance(sc, int): + detail = f"{detail} (status={sc})" + _log(server, "error", f"AI exception after {took_ms}ms: {detail}") + _log(server, "debug", f"AI traceback:\n{tb}") + # Still write a debug file for the screenshot for later inspection. + ai_path = png_path.with_suffix(".ai.json") + debug_obj = { + "error": "ai_exception", + "detail": detail, + "took_ms": took_ms, + "model": str(model), + "traceback": tb if getattr(server, "log_level", "info") == "debug" else "", + } + try: + ai_path.write_text(json.dumps(debug_obj, indent=2, ensure_ascii=True) + "\n", encoding="utf-8") + except Exception: + pass + return _ai_error_payload( + error="ai_exception", + detail=detail, + model=str(model), + response_id="", + status="", + incomplete_reason="", + usage=None, + took_ms=took_ms, + ai_path=ai_path, + ) - raw = resp.output_text or "" + response_id = str(getattr(resp, "id", "") or "") + status = str(getattr(resp, "status", "") or "") + incomplete_reason = "" + try: + incomplete_details = getattr(resp, "incomplete_details", None) + if incomplete_details is not None: + incomplete_reason = str(getattr(incomplete_details, "reason", "") or "") + except Exception: + incomplete_reason = "" + usage = _maybe_dump_model(getattr(resp, "usage", None)) + + _log( + server, + "debug", + f"AI response meta: status={status or '?'} incomplete_reason={incomplete_reason or '(none)'} usage={_safe_json_dump(usage or {}, 800)} response_id={response_id}", + ) + + raw = "" + try: + raw = resp.output_text or "" + except Exception: + raw = "" + _log(server, "debug", f"AI output_text chars={len(raw)} response_id={response_id}") + + parsed: object + parse_error = "" + parse_detail = "" try: parsed = json.loads(raw) - except Exception: - parsed = {"error": "non_json_output", "raw": raw} + except Exception as e: + # Try a couple conservative extraction heuristics. + candidate = _extract_json_text(raw) + if candidate: + try: + parsed = json.loads(candidate) + except Exception as e2: + parse_error = "non_json_output" + parse_detail = f"{type(e2).__name__}: {e2}" + parsed = {"error": "non_json_output", "raw": raw} + else: + parse_error = "non_json_output" + parse_detail = f"{type(e).__name__}: {e}" + parsed = {"error": "non_json_output", "raw": raw} + + if parse_error: + _log(server, "error", f"AI output parse failed: {parse_error} ({parse_detail}) response_id={response_id}") if isinstance(parsed, dict) and "posts" in parsed: parsed = _sanitize_ai_payload(parsed, page_url=page_url, page_title=page_title) ai_path = png_path.with_suffix(".ai.json") - ai_path.write_text(json.dumps(parsed, indent=2, ensure_ascii=True) + "\n", encoding="utf-8") - return {"ok": True, "ai": parsed, "ai_path": str(ai_path), "took_ms": took_ms} + try: + # When generation fails, persist helpful metadata along with the raw text. + if parse_error and isinstance(parsed, dict) and "raw" in parsed: + parsed = { + **parsed, + "response_id": response_id, + "model": str(model), + "status": status, + "incomplete_reason": incomplete_reason, + "usage": usage or {}, + } + ai_path.write_text(json.dumps(parsed, indent=2, ensure_ascii=True) + "\n", encoding="utf-8") + except Exception as e: + _log(server, "error", f"Failed writing AI file {ai_path}: {type(e).__name__}: {e}") + + # If we didn't get schema-conformant output, treat it as an error so the popup doesn't show "unknown". + if not _is_valid_ai_json(parsed): + err = parse_error or (parsed.get("error", "") if isinstance(parsed, dict) else "") or "invalid_ai_output" + detail = parse_detail or "AI returned JSON that does not match the expected schema (missing posts[])." + if incomplete_reason: + err = incomplete_reason if incomplete_reason in ("max_output_tokens", "content_filter") else err + if incomplete_reason == "max_output_tokens": + detail = ( + f"AI response was truncated (incomplete_reason=max_output_tokens). " + f"Try increasing --ai-max-output-tokens (currently {max_output_tokens}) or decreasing --ai-max-posts (currently {max_posts}). " + f"Original parse error: {parse_detail or '(none)'}" + ) + else: + detail = f"AI response incomplete (incomplete_reason={incomplete_reason}). Parse error: {parse_detail or '(none)'}" + _log(server, "error", f"AI output invalid: error={err} response_id={response_id} ai_path={ai_path}") + return _ai_error_payload( + error=str(err), + detail=str(detail), + raw=raw, + model=str(model), + response_id=response_id, + status=status, + incomplete_reason=incomplete_reason, + usage=usage, + took_ms=took_ms, + ai_path=ai_path, + ) + + posts_count = len(parsed.get("posts", [])) if isinstance(parsed, dict) else 0 + _log(server, "info", f"AI ok: posts={posts_count} took_ms={took_ms} response_id={response_id} ai_path={ai_path}") + return { + "ok": True, + "ai": parsed, + "ai_path": str(ai_path), + "took_ms": took_ms, + "response_id": response_id, + "model": str(model), + "status": status, + "incomplete_reason": incomplete_reason, + "usage": usage, + } class Handler(BaseHTTPRequestHandler): server_version = "LocalScreenshotBridge/0.1" + def log_message(self, fmt: str, *args): # noqa: N802 + # Route default HTTP request logs through our logger (and respect --log-level). + try: + msg = fmt % args + except Exception: + msg = fmt + try: + ip = self.client_address[0] if getattr(self, "client_address", None) else "?" + except Exception: + ip = "?" + _log(self.server, "debug", f"HTTP {ip} {msg}") # type: ignore[arg-type] + def _send_json(self, status: int, payload: dict): body = json.dumps(payload, ensure_ascii=True).encode("utf-8") self.send_response(status) @@ -368,6 +632,18 @@ class Handler(BaseHTTPRequestHandler): content = req.get("content", None) extra_instructions = req.get("extra_instructions") or "" + extra_s = str(extra_instructions).strip() + if extra_s: + extra_s = _truncate(extra_s.replace("\r", " ").replace("\n", " "), 140) + extra_part = f" extra={extra_s}" + else: + extra_part = "" + _log( + self.server, # type: ignore[arg-type] + "info", + f"Capture received: title={_truncate(str(title), 80)} url={_truncate(str(page_url), 140)} content={'yes' if content is not None else 'no'}{extra_part}", + ) + m = re.match(r"^data:image/png;base64,(.*)$", data_url) if not m: self._send_json(400, {"ok": False, "error": "expected_png_data_url"}) @@ -407,6 +683,7 @@ class Handler(BaseHTTPRequestHandler): content_path.write_text(raw_content, encoding="utf-8") wrote_content = True except Exception: + _log(self.server, "error", f"Failed writing content file {content_path}") # type: ignore[arg-type] # Don't fail the whole request if content writing fails. wrote_content = False @@ -442,6 +719,11 @@ class Handler(BaseHTTPRequestHandler): "content_path": final_content_path, "extra_instructions": extra_instructions, } + _log( + self.server, # type: ignore[arg-type] + "info", + f"Saved: png={png_path} meta={meta_path} content={final_content_path or '(none)'}", + ) run = getattr(self.server, "run_cmd", None) # type: ignore[attr-defined] ran = None @@ -464,15 +746,21 @@ class Handler(BaseHTTPRequestHandler): "stdout": proc.stdout[-4000:], "stderr": proc.stderr[-4000:], } + if proc.returncode != 0: + _log(self.server, "error", f"Hook failed: exit={proc.returncode} cmd={' '.join(run)}") # type: ignore[arg-type] except Exception as e: ran = {"cmd": run, "error": str(e)} + _log(self.server, "error", f"Hook exception: {type(e).__name__}: {e}") # type: ignore[arg-type] ai_result = None if getattr(self.server, "ai_enabled", False): # type: ignore[attr-defined] try: ai_result = _maybe_generate_ai(self.server, png_path, meta_obj, content) except Exception as e: - ai_result = {"ok": False, "error": "ai_exception", "detail": str(e)} + detail = f"{type(e).__name__}: {e}" + _log(self.server, "error", f"AI exception (outer): {detail}") # type: ignore[arg-type] + _log(self.server, "debug", f"AI outer traceback:\n{traceback.format_exc(limit=8)}") # type: ignore[arg-type] + ai_result = {"ok": False, "error": "ai_exception", "detail": detail} self._send_json( 200, @@ -493,6 +781,12 @@ def main(argv: list[str]) -> int: p.add_argument("--bind", default="127.0.0.1", help="Bind address (default: 127.0.0.1)") p.add_argument("--out-dir", default="screenshots", help="Output directory relative to project root") p.add_argument("--ai", action="store_true", help="Run OpenAI to generate reply suggestions and return them to the extension") + p.add_argument( + "--log-level", + default=os.getenv("AIEA_LOG_LEVEL", "info"), + choices=sorted(_LOG_LEVELS.keys()), + help="Logging verbosity: debug|info|error|quiet (env: AIEA_LOG_LEVEL)", + ) p.add_argument("--ai-model", default=os.getenv("AI_EA_MODEL", "gpt-5.2")) p.add_argument("--ai-max-posts", type=int, default=int(os.getenv("AI_EA_MAX_POSTS", "12"))) p.add_argument("--ai-content-max-chars", type=int, default=int(os.getenv("AI_EA_CONTENT_MAX_CHARS", "120000"))) @@ -518,6 +812,7 @@ def main(argv: list[str]) -> int: httpd.out_dir = out_dir # type: ignore[attr-defined] httpd.run_cmd = args.run # type: ignore[attr-defined] httpd.ai_enabled = bool(args.ai) # type: ignore[attr-defined] + httpd.log_level = args.log_level # type: ignore[attr-defined] httpd.ai_model = args.ai_model # type: ignore[attr-defined] httpd.ai_max_posts = args.ai_max_posts # type: ignore[attr-defined] httpd.ai_content_max_chars = args.ai_content_max_chars # type: ignore[attr-defined] @@ -527,6 +822,7 @@ def main(argv: list[str]) -> int: print(f"Listening on http://{args.bind}:{args.port}/screenshot", file=sys.stderr) print(f"Saving screenshots to {out_dir}", file=sys.stderr) + print(f"Log level: {args.log_level}", file=sys.stderr) if args.ai: print(f"OpenAI enabled: model={args.ai_model} max_posts={args.ai_max_posts}", file=sys.stderr) if args.run: