feat: add seo_meta service for AI-generated SEO metadata with CLI management and database layer
DevPlace CI / test (push) Failing after 2m13s
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:
@@ -259,6 +259,116 @@ def test_add_issue_usage_ignores_zero_call_batches(local_db):
|
||||
assert get_issue_usage()["calls"] == 0
|
||||
|
||||
|
||||
def test_seo_usage_accumulates_and_computes_averages(local_db):
|
||||
from devplacepy.database import add_seo_usage, get_seo_usage
|
||||
|
||||
get_table("seo_usage").delete()
|
||||
empty = get_seo_usage()
|
||||
assert empty["calls"] == 0
|
||||
assert empty["avg_tokens"] == 0.0
|
||||
assert empty["avg_cost_usd"] == 0.0
|
||||
|
||||
totals = {
|
||||
"calls": 2,
|
||||
"prompt_tokens": 2000,
|
||||
"completion_tokens": 400,
|
||||
"total_tokens": 2400,
|
||||
"cost_usd": 0.0005,
|
||||
"upstream_latency_ms": 1600.0,
|
||||
"total_latency_ms": 1800.0,
|
||||
}
|
||||
add_seo_usage(totals)
|
||||
add_seo_usage(totals)
|
||||
|
||||
usage = get_seo_usage()
|
||||
assert usage["calls"] == 4
|
||||
assert usage["total_tokens"] == 4800
|
||||
assert abs(usage["cost_usd"] - 0.001) < 1e-9
|
||||
assert usage["avg_tokens"] == 1200.0
|
||||
assert usage["avg_cost_usd"] == 0.00025
|
||||
assert usage["avg_tokens_per_second"] == 250.0
|
||||
|
||||
|
||||
def test_add_seo_usage_ignores_zero_call_batches(local_db):
|
||||
from devplacepy.database import add_seo_usage, get_seo_usage
|
||||
|
||||
get_table("seo_usage").delete()
|
||||
add_seo_usage({"calls": 0, "total_tokens": 999, "cost_usd": 1.0})
|
||||
assert get_seo_usage()["calls"] == 0
|
||||
|
||||
|
||||
def test_upsert_seo_metadata_inserts_then_updates_single_row(local_db):
|
||||
from devplacepy.database import (
|
||||
get_seo_metadata,
|
||||
has_fresh_seo_metadata,
|
||||
upsert_seo_metadata,
|
||||
)
|
||||
|
||||
target_uid = generate_uid()
|
||||
table = get_table("seo_metadata")
|
||||
|
||||
upsert_seo_metadata(
|
||||
"post", target_uid, "Plain Title", "Plain description", "alpha, beta", "pending", "plain"
|
||||
)
|
||||
assert has_fresh_seo_metadata("post", target_uid) is False
|
||||
assert get_seo_metadata("post", target_uid) is None
|
||||
|
||||
upsert_seo_metadata(
|
||||
"post", target_uid, "AI Title", "AI description", "python, sqlite", "ready", "ai"
|
||||
)
|
||||
ready = get_seo_metadata("post", target_uid)
|
||||
assert ready is not None
|
||||
assert ready["seo_title"] == "AI Title"
|
||||
assert ready["seo_keywords"] == "python, sqlite"
|
||||
assert ready["generated_at"]
|
||||
assert ready["deleted_at"] is None
|
||||
assert has_fresh_seo_metadata("post", target_uid) is True
|
||||
|
||||
rows = list(table.find(target_type="post", target_uid=target_uid))
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
def test_seo_metadata_batch_returns_only_ready_live_rows(local_db):
|
||||
from devplacepy.database import (
|
||||
get_seo_metadata_batch,
|
||||
upsert_seo_metadata,
|
||||
)
|
||||
|
||||
ready_uid = generate_uid()
|
||||
pending_uid = generate_uid()
|
||||
upsert_seo_metadata("gist", ready_uid, "T", "D", "k1, k2", "ready", "ai")
|
||||
upsert_seo_metadata("gist", pending_uid, "T", "D", "k1, k2", "pending", "plain")
|
||||
|
||||
batch = get_seo_metadata_batch("gist", [ready_uid, pending_uid])
|
||||
assert ready_uid in batch
|
||||
assert pending_uid not in batch
|
||||
assert batch[ready_uid]["status"] == "ready"
|
||||
|
||||
|
||||
def test_mark_seo_metadata_stale_drops_ready_status(local_db):
|
||||
from devplacepy.database import (
|
||||
get_seo_metadata,
|
||||
mark_seo_metadata_stale,
|
||||
upsert_seo_metadata,
|
||||
)
|
||||
|
||||
target_uid = generate_uid()
|
||||
upsert_seo_metadata("project", target_uid, "T", "D", "k", "ready", "ai")
|
||||
assert get_seo_metadata("project", target_uid) is not None
|
||||
|
||||
mark_seo_metadata_stale("project", target_uid)
|
||||
assert get_seo_metadata("project", target_uid) is None
|
||||
|
||||
|
||||
def test_seo_metadata_is_soft_deletable_and_indexed(local_db):
|
||||
from devplacepy.database import SOFT_DELETE_TABLES
|
||||
|
||||
assert "seo_metadata" in SOFT_DELETE_TABLES
|
||||
columns = get_table("seo_metadata").columns
|
||||
assert "deleted_at" in columns
|
||||
assert "deleted_by" in columns
|
||||
|
||||
|
||||
def test_interleave_by_author_spreads_consecutive_runs():
|
||||
from devplacepy.database import interleave_by_author
|
||||
|
||||
|
||||
Reference in New Issue
Block a user