add imrpvoed
This commit is contained in:
parent
0eb918c893
commit
beb64e2ed0
@ -27,6 +27,7 @@ Notes:
|
|||||||
- The server listens on `http://127.0.0.1:8765/screenshot`.
|
- 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 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 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.
|
- Optional: you can still use `--run ...` as a post-save hook.
|
||||||
|
|
||||||
## 2) Load the extension (unpacked)
|
## 2) Load the extension (unpacked)
|
||||||
|
|||||||
@ -65,6 +65,14 @@ Without OpenAI (save files only):
|
|||||||
python3 tools/local_screenshot_bridge.py --port 8765 --out-dir screenshots
|
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:
|
Health check:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -46,7 +46,14 @@ function renderResult(entryOrResp) {
|
|||||||
if (resp.ai_result) {
|
if (resp.ai_result) {
|
||||||
if (resp.ai_result.ok && resp.ai_result.ai && Array.isArray(resp.ai_result.ai.posts)) {
|
if (resp.ai_result.ok && resp.ai_result.ai && Array.isArray(resp.ai_result.ai.posts)) {
|
||||||
lines.push("");
|
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) {
|
for (const p of resp.ai_result.ai.posts) {
|
||||||
const idx = typeof p.index === "number" ? p.index : "?";
|
const idx = typeof p.index === "number" ? p.index : "?";
|
||||||
const postText = (p.post_text || "").replace(/\s+/g, " ").trim();
|
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.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 {
|
} else {
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(`AI error: ${resp.ai_result.error || "unknown"}`);
|
const ms = resp.ai_result.took_ms || "?";
|
||||||
if (resp.ai_result.detail) lines.push(`Detail: ${resp.ai_result.detail}`);
|
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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.",
|
"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": {
|
"output_requirements": {
|
||||||
#"ironic": "Lightly ironic, laughing at us humans (not cruel).",
|
#"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.",
|
"critical": "Bold/critical: politely questions the premise or assumptions.",
|
||||||
"suggested": "Best style you think fits (helpful/witty/clarifying/etc).",
|
"suggested": "Best style you think fits (helpful/witty/clarifying/etc).",
|
||||||
"short": "1-2 sentences, direct, useful, no fluff.",
|
"short": "1-2 sentences, direct, useful, no fluff.",
|
||||||
|
|||||||
@ -7,9 +7,33 @@ import re
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import traceback
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
from pathlib import Path
|
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:
|
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)
|
s = json.dumps(obj, ensure_ascii=True, separators=(",", ":"), sort_keys=False)
|
||||||
return _truncate(s, max_chars)
|
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:
|
def _ea_sanitize_text(text: object) -> str:
|
||||||
"""
|
"""
|
||||||
Port of fl_geo_sanitize_text(), plus lowercase output (no capitals).
|
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)
|
_load_dotenv_if_present(project_root)
|
||||||
|
|
||||||
if not os.getenv("OPENAI_API_KEY"):
|
if not os.getenv("OPENAI_API_KEY"):
|
||||||
|
_log(server, "error", "AI disabled: missing OPENAI_API_KEY")
|
||||||
return {"ok": False, "error": "missing_openai_api_key"}
|
return {"ok": False, "error": "missing_openai_api_key"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from openai import OpenAI # type: ignore
|
from openai import OpenAI # type: ignore
|
||||||
except Exception as e:
|
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)}
|
return {"ok": False, "error": "missing_openai_sdk", "detail": str(e)}
|
||||||
|
|
||||||
instructions_text = getattr(server, "ai_instructions", "")
|
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 "")
|
page_title = str(meta.get("title") or "")
|
||||||
extra_instructions = str(meta.get("extra_instructions") or "").strip()
|
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 = {
|
user_payload = {
|
||||||
"page_url": page_url,
|
"page_url": page_url,
|
||||||
"page_title": page_title,
|
"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.",
|
"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": {
|
"output_requirements": {
|
||||||
# "ironic": "Lightly ironic, laughing at us humans (not cruel).",
|
# "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.",
|
"critical": "Bold/critical: politely questions the premise or assumptions.",
|
||||||
"suggested": "Best style you think fits (helpful/witty/clarifying/etc).",
|
"suggested": "Best style you think fits (helpful/witty/clarifying/etc).",
|
||||||
"short": "1-2 sentences, direct, useful, no fluff.",
|
"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()
|
t0 = time.monotonic()
|
||||||
client = OpenAI()
|
client = OpenAI()
|
||||||
resp = client.responses.create(
|
resp = None
|
||||||
model=model,
|
took_ms = None
|
||||||
instructions=instructions_text,
|
try:
|
||||||
input=[
|
resp = client.responses.create(
|
||||||
{
|
model=model,
|
||||||
"role": "user",
|
instructions=instructions_text,
|
||||||
"content": [
|
input=[
|
||||||
{"type": "input_text", "text": prompt_text},
|
{
|
||||||
{"type": "input_image", "image_url": image_data_url, "detail": image_detail},
|
"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",
|
text={
|
||||||
"description": "Draft short and medium replies for each visible post on the page.",
|
"format": {
|
||||||
"schema": _response_schema(max_posts),
|
"type": "json_schema",
|
||||||
"strict": True,
|
"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,
|
||||||
},
|
)
|
||||||
max_output_tokens=max_output_tokens,
|
took_ms = int((time.monotonic() - t0) * 1000)
|
||||||
)
|
except Exception as e:
|
||||||
took_ms = int((time.monotonic() - t0) * 1000)
|
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:
|
try:
|
||||||
parsed = json.loads(raw)
|
parsed = json.loads(raw)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
parsed = {"error": "non_json_output", "raw": raw}
|
# 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:
|
if isinstance(parsed, dict) and "posts" in parsed:
|
||||||
parsed = _sanitize_ai_payload(parsed, page_url=page_url, page_title=page_title)
|
parsed = _sanitize_ai_payload(parsed, page_url=page_url, page_title=page_title)
|
||||||
|
|
||||||
ai_path = png_path.with_suffix(".ai.json")
|
ai_path = png_path.with_suffix(".ai.json")
|
||||||
ai_path.write_text(json.dumps(parsed, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
|
try:
|
||||||
return {"ok": True, "ai": parsed, "ai_path": str(ai_path), "took_ms": took_ms}
|
# 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):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
server_version = "LocalScreenshotBridge/0.1"
|
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):
|
def _send_json(self, status: int, payload: dict):
|
||||||
body = json.dumps(payload, ensure_ascii=True).encode("utf-8")
|
body = json.dumps(payload, ensure_ascii=True).encode("utf-8")
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
@ -368,6 +632,18 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
content = req.get("content", None)
|
content = req.get("content", None)
|
||||||
extra_instructions = req.get("extra_instructions") or ""
|
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)
|
m = re.match(r"^data:image/png;base64,(.*)$", data_url)
|
||||||
if not m:
|
if not m:
|
||||||
self._send_json(400, {"ok": False, "error": "expected_png_data_url"})
|
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")
|
content_path.write_text(raw_content, encoding="utf-8")
|
||||||
wrote_content = True
|
wrote_content = True
|
||||||
except Exception:
|
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.
|
# Don't fail the whole request if content writing fails.
|
||||||
wrote_content = False
|
wrote_content = False
|
||||||
|
|
||||||
@ -442,6 +719,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"content_path": final_content_path,
|
"content_path": final_content_path,
|
||||||
"extra_instructions": extra_instructions,
|
"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]
|
run = getattr(self.server, "run_cmd", None) # type: ignore[attr-defined]
|
||||||
ran = None
|
ran = None
|
||||||
@ -464,15 +746,21 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"stdout": proc.stdout[-4000:],
|
"stdout": proc.stdout[-4000:],
|
||||||
"stderr": proc.stderr[-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:
|
except Exception as e:
|
||||||
ran = {"cmd": run, "error": str(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
|
ai_result = None
|
||||||
if getattr(self.server, "ai_enabled", False): # type: ignore[attr-defined]
|
if getattr(self.server, "ai_enabled", False): # type: ignore[attr-defined]
|
||||||
try:
|
try:
|
||||||
ai_result = _maybe_generate_ai(self.server, png_path, meta_obj, content)
|
ai_result = _maybe_generate_ai(self.server, png_path, meta_obj, content)
|
||||||
except Exception as e:
|
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(
|
self._send_json(
|
||||||
200,
|
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("--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("--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("--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-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-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")))
|
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.out_dir = out_dir # type: ignore[attr-defined]
|
||||||
httpd.run_cmd = args.run # type: ignore[attr-defined]
|
httpd.run_cmd = args.run # type: ignore[attr-defined]
|
||||||
httpd.ai_enabled = bool(args.ai) # 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_model = args.ai_model # type: ignore[attr-defined]
|
||||||
httpd.ai_max_posts = args.ai_max_posts # 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]
|
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"Listening on http://{args.bind}:{args.port}/screenshot", file=sys.stderr)
|
||||||
print(f"Saving screenshots to {out_dir}", 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:
|
if args.ai:
|
||||||
print(f"OpenAI enabled: model={args.ai_model} max_posts={args.ai_max_posts}", file=sys.stderr)
|
print(f"OpenAI enabled: model={args.ai_model} max_posts={args.ai_max_posts}", file=sys.stderr)
|
||||||
if args.run:
|
if args.run:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user