add imrpvoed

This commit is contained in:
okendoken 2026-02-16 23:00:15 +01:00
parent 0eb918c893
commit beb64e2ed0
5 changed files with 377 additions and 35 deletions

View File

@ -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)

View File

@ -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

View File

@ -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}`);
}
}

View File

@ -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.",

View File

@ -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: