feat: add seo_meta service for AI-generated SEO metadata with CLI management and database layer
DevPlace CI / test (push) Failing after 2m13s

Implement a new `SeoMetaService` subservice that generates clean SEO title/description/keywords for published content items, distinct from the existing SEO diagnostics auditor. Add `seo_metadata` polymorphic table with soft-delete support, batch query methods, and usage tracking. Extend the CLI with `seo-meta prune` and `seo-meta clear` commands for job row lifecycle management. Wire `schedule_seo_meta_for_table` into content creation and editing flows in `content.py`. Document the new service in `AGENTS.md` and `README.md`, including the `extra_head` site setting for custom `<head>` injection.
This commit is contained in:
2026-06-19 20:15:22 +00:00
parent 426d3639c6
commit d10f1af118
51 changed files with 2262 additions and 93 deletions
+3 -1
View File
@@ -16,6 +16,8 @@ from devplacepy.dependencies import json_or_form
logger = logging.getLogger(__name__)
router = APIRouter()
CLEARABLE_SETTINGS = {"extra_head"}
@router.get("/settings", response_class=HTMLResponse)
async def admin_settings(request: Request):
admin = require_admin(request)
@@ -57,7 +59,7 @@ async def admin_settings_save(
for key, value in data.model_dump().items():
existing = settings.find_one(key=key)
if existing:
if value == "":
if value == "" and key not in CLEARABLE_SETTINGS:
continue
old_value = existing.get("value")
if str(old_value) == str(value):
+2 -1
View File
@@ -156,7 +156,8 @@ async def gist_detail(request: Request, gist_slug: str):
seo_ctx = base_seo_context(
request,
title=gist.get("title", "Gist"),
description=(gist.get("description") or "")[:160],
description=gist.get("description") or "",
seo_target=("gist", gist["uid"]),
og_type="article",
og_image=first_image_url(gist, detail["attachments"]),
breadcrumbs=[
+2 -1
View File
@@ -140,7 +140,8 @@ async def issue_detail(request: Request, number: int):
seo_ctx = issue_seo(
request,
title=f"Issue #{number}: {issue.get('title', '')}",
description=issue.get("title", ""),
description=body or issue.get("title", ""),
seo_target=("issue", str(number)),
)
return respond(
request,
+2 -1
View File
@@ -124,7 +124,8 @@ async def news_detail_page(request: Request, news_slug: str):
seo_ctx = base_seo_context(
request,
title=article.get("title", "News Article"),
description=(article.get("description", "") or "")[:200],
description=article.get("description", "") or "",
seo_target=("news", article["uid"]),
og_type="article",
og_image=image_url or None,
breadcrumbs=[
+2 -2
View File
@@ -31,7 +31,6 @@ from devplacepy.seo import (
site_url,
website_schema,
discussion_forum_posting,
truncate,
)
from devplacepy.attachments import save_inline_image
from devplacepy.models import PostForm, PostEditForm
@@ -153,7 +152,8 @@ async def view_post(request: Request, post_slug: str):
seo_ctx = base_seo_context(
request,
title=post.get("title") or "Post",
description=truncate(post.get("content", ""), 160),
description=post.get("content", ""),
seo_target=("post", post["uid"]),
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Feed", "url": "/feed"},
+2 -1
View File
@@ -182,7 +182,8 @@ async def project_detail(request: Request, project_slug: str):
seo_ctx = base_seo_context(
request,
title=project.get("title", "Project"),
description=project.get("description", "")[:160],
description=project.get("description", ""),
seo_target=("project", project["uid"]),
robots=robots,
og_image=first_image_url(project, detail["attachments"]),
breadcrumbs=[
+46 -1
View File
@@ -3,8 +3,11 @@
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, JSONResponse
from devplacepy.database import SEO_META_TYPES, get_seo_metadata
from devplacepy.schemas import SeoMetaOut
from devplacepy.services.seo_meta import defaults_for_target
from devplacepy.templating import templates
from devplacepy.seo import list_page_seo
from devplacepy.utils import get_current_user
@@ -54,3 +57,45 @@ async def tools_index(request: Request):
"tools/index.html",
{**seo_ctx, "request": request, "user": user, "tools": TOOLS},
)
@router.get("/seo-meta/{target_type}/{target_uid}")
async def seo_meta_status(request: Request, target_type: str, target_uid: str):
if target_type not in SEO_META_TYPES:
return JSONResponse(
{"error": "unknown target type"}, status_code=400
)
if target_type == "project":
from devplacepy.content import can_view_project
from devplacepy.database import get_table
project = get_table("projects").find_one(uid=target_uid, deleted_at=None)
if not can_view_project(dict(project) if project else None, get_current_user(request)):
return JSONResponse({"error": "not found"}, status_code=404)
row = get_seo_metadata(target_type, target_uid)
if row:
payload = {
"uid": row.get("uid") or "",
"target_type": target_type,
"target_uid": target_uid,
"seo_title": row.get("seo_title") or "",
"seo_description": row.get("seo_description") or "",
"seo_keywords": row.get("seo_keywords") or "",
"status": row.get("status") or "ready",
"source": row.get("source") or "ai",
"generated_at": row.get("generated_at") or None,
}
else:
defaults = defaults_for_target(target_type, target_uid)
payload = {
"uid": "",
"target_type": target_type,
"target_uid": target_uid,
"seo_title": defaults["title"],
"seo_description": defaults["description"],
"seo_keywords": defaults["keywords"],
"status": "pending",
"source": "plain",
"generated_at": None,
}
return JSONResponse(SeoMetaOut(**payload).model_dump())