|
# retoor <retoor@molodetz.nl>
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
|
|
from devplacepy.database import (
|
|
add_seo_usage,
|
|
get_seo_usage,
|
|
get_table,
|
|
has_fresh_seo_metadata,
|
|
internal_gateway_key,
|
|
upsert_seo_metadata,
|
|
)
|
|
from devplacepy.seo_meta_text import (
|
|
DESCRIPTION_MAX,
|
|
TITLE_MAX,
|
|
clamp_generated,
|
|
plain_seo_defaults,
|
|
)
|
|
from devplacepy.services.ai_context import build_context
|
|
from devplacepy.services.base import ConfigField
|
|
from devplacepy.services.correction import gateway_complete, new_usage_totals
|
|
from devplacepy.services.jobs import queue
|
|
from devplacepy.services.jobs.base import JobService
|
|
from devplacepy.services.openai_gateway.usage import usage_metric_cards
|
|
from devplacepy.services.seo_meta import (
|
|
BODY_FIELD,
|
|
TITLE_FIELD,
|
|
TYPE_TABLES,
|
|
load_target_row,
|
|
schedule_seo_meta,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
GENERATION_TIMEOUT_SECONDS = 30.0
|
|
|
|
SYSTEM_PROMPT = (
|
|
"You are an SEO metadata engine for a developer social network. From the content "
|
|
"below produce search-optimized metadata. Return ONLY a strict JSON object with the "
|
|
'keys "seo_title", "seo_description" and "seo_keywords" and nothing else. Rules: '
|
|
f"seo_title is at most {TITLE_MAX} characters, front-loads the primary keyword, uses "
|
|
"a single hyphen as separator, and contains no markdown. seo_description is at most "
|
|
f"{DESCRIPTION_MAX} characters, places the key message within the first 120 "
|
|
"characters, reads naturally, and contains no markdown. seo_keywords is a short "
|
|
"honest comma-separated list of 5 to 8 lowercase terms, never keyword-stuffed. Use a "
|
|
"plain hyphen, never an em-dash."
|
|
)
|
|
|
|
|
|
class SeoMetaService(JobService):
|
|
kind = "seo_meta"
|
|
title = "SEO Metadata"
|
|
description = (
|
|
"Generates clean SEO title, description and keywords for every published post, "
|
|
"project, gist, news article and issue off the request path, and meters its own "
|
|
"AI spend. Until the AI value is ready a plain-content default is served so the "
|
|
"fields are never empty. The seo_metadata row is permanent; only the job tracking "
|
|
"row is swept on retention."
|
|
)
|
|
|
|
def __init__(self):
|
|
super().__init__(name="seo_meta", interval_seconds=10)
|
|
self.config_fields = list(self.config_fields) + [
|
|
ConfigField(
|
|
"seo_meta_backfill_enabled",
|
|
"Backfill existing content",
|
|
type="bool",
|
|
default=True,
|
|
help="Generate metadata for already-published content lacking it.",
|
|
group="SEO metadata",
|
|
),
|
|
ConfigField(
|
|
"seo_meta_backfill_batch",
|
|
"Backfill items per tick",
|
|
type="int",
|
|
default=5,
|
|
minimum=1,
|
|
maximum=100,
|
|
help="How many published items to enqueue for backfill each tick.",
|
|
group="SEO metadata",
|
|
),
|
|
]
|
|
|
|
async def process(self, job: dict) -> dict:
|
|
payload = job["payload"]
|
|
target_type = payload.get("target_type", "")
|
|
target_uid = str(payload.get("target_uid", ""))
|
|
row = self._load_row(target_type, target_uid)
|
|
title = (row or {}).get(TITLE_FIELD.get(target_type, "title")) or ""
|
|
body = (row or {}).get(BODY_FIELD.get(target_type, "content")) or ""
|
|
|
|
defaults = plain_seo_defaults(title, body)
|
|
if not row:
|
|
upsert_seo_metadata(
|
|
target_type,
|
|
target_uid,
|
|
defaults["title"],
|
|
defaults["description"],
|
|
defaults["keywords"],
|
|
"failed",
|
|
"plain",
|
|
)
|
|
self._audit(target_type, target_uid, "plain", title, result="failure")
|
|
return {"target_type": target_type, "target_uid": target_uid, "source": "plain"}
|
|
|
|
totals = new_usage_totals()
|
|
result = await asyncio.to_thread(
|
|
self._generate, target_type, target_uid, row, title, body, defaults, totals
|
|
)
|
|
if totals["calls"]:
|
|
add_seo_usage(totals)
|
|
upsert_seo_metadata(
|
|
target_type,
|
|
target_uid,
|
|
result["title"],
|
|
result["description"],
|
|
result["keywords"],
|
|
"ready" if result["source"] == "ai" else "failed",
|
|
result["source"],
|
|
)
|
|
self._audit(
|
|
target_type,
|
|
target_uid,
|
|
result["source"],
|
|
result["title"],
|
|
result="success" if result["source"] == "ai" else "failure",
|
|
)
|
|
return {
|
|
"target_type": target_type,
|
|
"target_uid": target_uid,
|
|
"source": result["source"],
|
|
"title_len": len(result["title"]),
|
|
"desc_len": len(result["description"]),
|
|
}
|
|
|
|
def _generate(
|
|
self,
|
|
target_type: str,
|
|
target_uid: str,
|
|
row: dict,
|
|
title: str,
|
|
body: str,
|
|
defaults: dict,
|
|
totals: dict,
|
|
) -> dict:
|
|
context = ""
|
|
try:
|
|
context = build_context(
|
|
TYPE_TABLES.get(target_type, target_type),
|
|
target_uid,
|
|
row,
|
|
row.get("user_uid", "") or "",
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("seo meta context failed: %s", exc)
|
|
source_text = f"{context}\n\nTITLE: {title}\n\nCONTENT:\n{body}".strip()
|
|
raw, usage = gateway_complete(
|
|
internal_gateway_key(),
|
|
SYSTEM_PROMPT,
|
|
source_text,
|
|
GENERATION_TIMEOUT_SECONDS,
|
|
)
|
|
if usage:
|
|
for key in totals:
|
|
totals[key] += usage.get(key, 0)
|
|
parsed = self._parse(raw)
|
|
if not parsed:
|
|
return {**defaults, "source": "plain"}
|
|
clamped = clamp_generated(
|
|
parsed.get("seo_title") or defaults["title"],
|
|
parsed.get("seo_description") or defaults["description"],
|
|
parsed.get("seo_keywords") or defaults["keywords"],
|
|
)
|
|
return {
|
|
"title": clamped["title"] or defaults["title"],
|
|
"description": clamped["description"] or defaults["description"],
|
|
"keywords": clamped["keywords"] or defaults["keywords"],
|
|
"source": "ai",
|
|
}
|
|
|
|
def _parse(self, raw: str) -> dict | None:
|
|
if not raw:
|
|
return None
|
|
text = raw.strip()
|
|
if text.startswith("```"):
|
|
text = text.strip("`")
|
|
if text.lower().startswith("json"):
|
|
text = text[4:]
|
|
start = text.find("{")
|
|
end = text.rfind("}")
|
|
if start == -1 or end == -1 or end <= start:
|
|
return None
|
|
try:
|
|
data = json.loads(text[start : end + 1])
|
|
except (ValueError, TypeError):
|
|
return None
|
|
return data if isinstance(data, dict) else None
|
|
|
|
def _load_row(self, target_type: str, target_uid: str) -> dict | None:
|
|
return load_target_row(target_type, target_uid)
|
|
|
|
def _audit(
|
|
self, target_type: str, target_uid: str, source: str, title: str, result: str
|
|
) -> None:
|
|
from devplacepy.services.audit import record as audit
|
|
|
|
event = "seo.meta.generate" if result == "success" else "seo.meta.failed"
|
|
audit.record_system(
|
|
event,
|
|
actor_kind="service",
|
|
origin="service",
|
|
result=result,
|
|
target_type=target_type,
|
|
target_uid=target_uid,
|
|
target_label=title[:120],
|
|
metadata={"source": source},
|
|
summary=f"seo metadata {result} for {target_type} {target_uid} ({source})",
|
|
)
|
|
|
|
def cleanup(self, job: dict) -> None:
|
|
pass
|
|
|
|
async def run_once(self) -> None:
|
|
await super().run_once()
|
|
try:
|
|
self._backfill()
|
|
except Exception as exc:
|
|
logger.warning("seo meta backfill failed: %s", exc)
|
|
|
|
def _backfill(self) -> None:
|
|
from devplacepy.database import get_int_setting, get_setting
|
|
|
|
if get_setting("seo_meta_backfill_enabled", "1") == "0":
|
|
return
|
|
batch = max(1, get_int_setting("seo_meta_backfill_batch", 5))
|
|
for target_type, table in TYPE_TABLES.items():
|
|
self._backfill_type(target_type, table, batch)
|
|
|
|
def _backfill_type(self, target_type: str, table: str, batch: int) -> None:
|
|
from devplacepy.database import db
|
|
|
|
if table not in db.tables:
|
|
return
|
|
criteria = {"deleted_at": None, "_limit": batch * 4, "order_by": ["-created_at"]}
|
|
if target_type == "news":
|
|
criteria["status"] = "published"
|
|
enqueued = 0
|
|
for row in get_table(table).find(**criteria):
|
|
if enqueued >= batch:
|
|
break
|
|
uid = row.get("uid")
|
|
if not uid or has_fresh_seo_metadata(target_type, uid):
|
|
continue
|
|
schedule_seo_meta(target_type, uid)
|
|
enqueued += 1
|
|
|
|
def collect_metrics(self) -> dict:
|
|
base = super().collect_metrics()
|
|
base["stats"] = base["stats"] + usage_metric_cards(get_seo_usage())
|
|
return base
|