feat: add seo_meta service for AI-generated SEO metadata with CLI management and database layer

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
+23 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from .embeddings import embed_texts, local_embed
@@ -11,6 +12,8 @@ from .store import Chunk, VectorStore
logger = logging.getLogger(__name__)
CITATION_MARKER = re.compile(r"\[(\d+)\]")
CHAT_TOP_K = 8
MAX_CONTEXT_CHARS = 9000
CHAT_MAX_TOKENS = 900
@@ -63,12 +66,22 @@ class DeepsearchChat:
async def _embed_query(self, question: str) -> list[float]:
result = await embed_texts([question], self.api_key)
if not result.vectors:
if not result.vectors or not result.vectors[0]:
result = local_embed([question])
return result.vectors[0]
async def retrieve(self, question: str) -> list[Chunk]:
query_vector = await self._embed_query(question)
stored_dim = self.store.dims
if stored_dim is not None and len(query_vector) != stored_dim:
query_vector = local_embed([question]).vectors[0]
if len(query_vector) != stored_dim:
logger.warning(
"deepsearch query embedding dim %d != stored %d",
len(query_vector),
stored_dim,
)
return []
return self.store.hybrid_search(question, query_vector, top_k=CHAT_TOP_K)
async def answer(self, question: str, history: list[dict] | None = None) -> ChatAnswer:
@@ -104,4 +117,13 @@ class DeepsearchChat:
"I could not reach the language model to synthesise an answer, but the "
"most relevant sources are listed below."
)
valid = {citation["index"] for citation in citations}
text = self._strip_unmatched_markers(text, valid)
return ChatAnswer(text=text, citations=citations)
def _strip_unmatched_markers(self, text: str, valid: set[int]) -> str:
def replace(match: re.Match) -> str:
index = int(match.group(1))
return match.group(0) if index in valid else ""
return CITATION_MARKER.sub(replace, text)