task015 implement seo and language review flow

This commit is contained in:
2026-05-22 00:16:08 +03:00
parent 8243f20b0e
commit 6dcbe0e36c
20 changed files with 3828 additions and 14 deletions
+645
View File
@@ -0,0 +1,645 @@
from __future__ import annotations
import re
from datetime import UTC, datetime
from uuid import NAMESPACE_URL, UUID, uuid5
from src.application.drafts import update_draft
from src.domain.contracts import (
ArticleWorkflowStatus,
ContentReviewKind,
ContentReviewReportSummary,
ContentReviewSuggestionSummary,
DraftSummary,
DraftUpdateRequest,
FinalReviewIssueSummary,
FinalReviewIssuesResponse,
LanguageReviewReportResponse,
LanguageReviewRunResponse,
ReviewSuggestionActionResponse,
ReviewSuggestionEditRequest,
ReviewSuggestionStatus,
SeoReviewReportResponse,
SeoReviewRunResponse,
)
DEFAULT_SEO_RULES = {
"title_min": 30,
"title_max": 60,
"meta_title_min": 30,
"meta_title_max": 60,
"meta_description_min": 120,
"meta_description_max": 160,
"schema_type": "Article",
}
DEFAULT_LANGUAGE_RULES = {
"max_sentence_words": 24,
"forbidden_phrases": ["best ever", "guaranteed"],
"brand_tone": "neutral",
}
def run_seo_review(repository: object, *, article_id: UUID) -> SeoReviewRunResponse:
article = repository.articles.get(article_id)
draft = _require_latest_draft(repository, article_id=article_id)
site = repository.target_sites.get_by_id(article.target_site_id)
rules = _resolve_seo_rules(site.seo_rules)
issues = _build_seo_issues(
article_id=article_id,
title=draft.title,
meta_title=draft.meta_title or draft.title,
meta_description=draft.meta_description or "",
body_markdown=draft.body_markdown,
primary_keyword=article.primary_keyword or "",
rules=rules,
)
recommended_title = _recommended_title(
title=draft.title,
primary_keyword=article.primary_keyword or "",
)
recommended_slug = _slugify(recommended_title)
score = max(0, 100 - len(issues) * 12)
now = _now()
report = repository.content_reviews.create_report(
article_id=article_id,
review_kind=ContentReviewKind.SEO,
draft_id=draft.id,
score=score,
recommended_slug=recommended_slug,
recommended_title=recommended_title,
schema_json={
"@context": "https://schema.org",
"@type": str(rules.get("schema_type", "Article")),
"headline": recommended_title,
"inLanguage": article.language,
},
rules_snapshot=rules,
created_at=now,
)
_upsert_report_suggestions(
repository,
article_id=article_id,
report_id=report.id,
review_kind=ContentReviewKind.SEO,
issues=issues,
timestamp=now,
)
latest = repository.content_reviews.get_report(
article_id=article_id,
report_id=report.id,
)
_mark_review_ready(repository, article_id=article_id, event_type="SEO_REVIEW_COMPLETED", count=len(latest.issues))
return SeoReviewRunResponse(report=latest)
def get_seo_report(repository: object, *, article_id: UUID) -> SeoReviewReportResponse:
repository.articles.get(article_id)
report = repository.content_reviews.latest_report(
article_id=article_id,
review_kind=ContentReviewKind.SEO,
)
if report is None:
raise LookupError(f"SEO report not found for article: {article_id}")
return SeoReviewReportResponse(report=report)
def run_language_review(repository: object, *, article_id: UUID) -> LanguageReviewRunResponse:
article = repository.articles.get(article_id)
draft = _require_latest_draft(repository, article_id=article_id)
site = repository.target_sites.get_by_id(article.target_site_id)
rules = _resolve_language_rules(site.seo_rules, brand_voice=site.brand_voice)
issues = _build_language_issues(
article_id=article_id,
body_markdown=draft.body_markdown,
rules=rules,
)
score = max(0, 100 - len(issues) * 14)
now = _now()
report = repository.content_reviews.create_report(
article_id=article_id,
review_kind=ContentReviewKind.LANGUAGE,
draft_id=draft.id,
score=score,
recommended_slug=draft.slug,
recommended_title=draft.title,
schema_json={
"tone": rules.get("brand_tone"),
"language": article.language,
"max_sentence_words": rules.get("max_sentence_words"),
},
rules_snapshot=rules,
created_at=now,
)
_upsert_report_suggestions(
repository,
article_id=article_id,
report_id=report.id,
review_kind=ContentReviewKind.LANGUAGE,
issues=issues,
timestamp=now,
)
latest = repository.content_reviews.get_report(
article_id=article_id,
report_id=report.id,
)
_mark_review_ready(
repository,
article_id=article_id,
event_type="LANGUAGE_REVIEW_COMPLETED",
count=len(latest.issues),
)
return LanguageReviewRunResponse(report=latest)
def get_language_report(
repository: object,
*,
article_id: UUID,
) -> LanguageReviewReportResponse:
repository.articles.get(article_id)
report = repository.content_reviews.latest_report(
article_id=article_id,
review_kind=ContentReviewKind.LANGUAGE,
)
if report is None:
raise LookupError(f"Language report not found for article: {article_id}")
return LanguageReviewReportResponse(report=report)
def accept_suggestion(
repository: object,
*,
article_id: UUID,
review_kind: ContentReviewKind,
suggestion_id: UUID,
) -> ReviewSuggestionActionResponse:
suggestion = repository.content_reviews.get_suggestion(
article_id=article_id,
review_kind=review_kind,
suggestion_id=suggestion_id,
)
draft = _apply_suggestion_to_draft(
repository,
article_id=article_id,
suggestion=suggestion,
override_text=None,
)
updated = repository.content_reviews.update_suggestion(
article_id=article_id,
review_kind=review_kind,
suggestion_id=suggestion_id,
status=ReviewSuggestionStatus.ACCEPTED,
applied_text=None,
updated_at=_now(),
)
return ReviewSuggestionActionResponse(suggestion=updated, draft=draft)
def reject_suggestion(
repository: object,
*,
article_id: UUID,
review_kind: ContentReviewKind,
suggestion_id: UUID,
) -> ReviewSuggestionActionResponse:
repository.articles.get(article_id)
updated = repository.content_reviews.update_suggestion(
article_id=article_id,
review_kind=review_kind,
suggestion_id=suggestion_id,
status=ReviewSuggestionStatus.REJECTED,
applied_text=None,
updated_at=_now(),
)
return ReviewSuggestionActionResponse(suggestion=updated, draft=None)
def edit_suggestion(
repository: object,
*,
article_id: UUID,
review_kind: ContentReviewKind,
suggestion_id: UUID,
request: ReviewSuggestionEditRequest,
) -> ReviewSuggestionActionResponse:
suggestion = repository.content_reviews.get_suggestion(
article_id=article_id,
review_kind=review_kind,
suggestion_id=suggestion_id,
)
draft = _apply_suggestion_to_draft(
repository,
article_id=article_id,
suggestion=suggestion,
override_text=request.applied_text,
)
updated = repository.content_reviews.update_suggestion(
article_id=article_id,
review_kind=review_kind,
suggestion_id=suggestion_id,
status=ReviewSuggestionStatus.EDITED,
applied_text=request.applied_text,
updated_at=_now(),
)
return ReviewSuggestionActionResponse(suggestion=updated, draft=draft)
def get_final_review_issues(
repository: object,
*,
article_id: UUID,
) -> FinalReviewIssuesResponse:
repository.articles.get(article_id)
unresolved = repository.content_reviews.list_unresolved_for_article(article_id=article_id)
return FinalReviewIssuesResponse(
article_id=article_id,
unresolved_count=len(unresolved),
unresolved_issues=[
FinalReviewIssueSummary(
review_kind=item.review_kind,
suggestion_id=item.id,
severity=item.severity,
location=item.location,
message=item.message,
status=item.status,
)
for item in unresolved
],
)
def _require_latest_draft(repository: object, *, article_id: UUID) -> DraftSummary:
draft = repository.article_drafts.latest_for_article(article_id)
if draft is None:
raise PermissionError("Assembled draft is required before review")
return draft
def _resolve_seo_rules(seo_rules: dict[str, object]) -> dict[str, object]:
overrides = seo_rules.get("seo_review", {}) if isinstance(seo_rules, dict) else {}
result = dict(DEFAULT_SEO_RULES)
if isinstance(overrides, dict):
result.update(overrides)
return result
def _resolve_language_rules(
seo_rules: dict[str, object],
*,
brand_voice: str,
) -> dict[str, object]:
overrides = seo_rules.get("language_review", {}) if isinstance(seo_rules, dict) else {}
result = dict(DEFAULT_LANGUAGE_RULES)
if isinstance(overrides, dict):
result.update(overrides)
if not result.get("brand_tone"):
result["brand_tone"] = brand_voice
return result
def _build_seo_issues(
*,
article_id: UUID,
title: str,
meta_title: str,
meta_description: str,
body_markdown: str,
primary_keyword: str,
rules: dict[str, object],
) -> list[dict[str, object]]:
issues: list[dict[str, object]] = []
title_min = int(rules.get("meta_title_min", 30))
title_max = int(rules.get("meta_title_max", 60))
if len(meta_title) < title_min or len(meta_title) > title_max:
suggested = _trim(meta_title or title, min_len=title_min, max_len=title_max)
issues.append(
{
"id": "meta-title-length",
"severity": "HIGH",
"location": "meta_title",
"message": f"Meta title length should be between {title_min} and {title_max} characters.",
"suggested_fix": f"Use a {title_min}-{title_max} character meta title.",
"suggested_rewrite": suggested,
"patch": {"field": "meta_title", "value": suggested},
}
)
desc_min = int(rules.get("meta_description_min", 120))
desc_max = int(rules.get("meta_description_max", 160))
if len(meta_description) < desc_min or len(meta_description) > desc_max:
suggested_description = _trim(meta_description or title, min_len=desc_min, max_len=desc_max)
issues.append(
{
"id": "meta-description-length",
"severity": "HIGH",
"location": "meta_description",
"message": f"Meta description length should be between {desc_min} and {desc_max} characters.",
"suggested_fix": "Adjust meta description length to fit SEO rules.",
"suggested_rewrite": suggested_description,
"patch": {"field": "meta_description", "value": suggested_description},
}
)
if primary_keyword:
normalized_keyword = primary_keyword.lower()
if normalized_keyword not in body_markdown.lower():
append_sentence = f" This guide addresses {primary_keyword} with concrete steps."
issues.append(
{
"id": "keyword-placement",
"severity": "MEDIUM",
"location": "body_markdown",
"message": "Primary keyword is missing from draft body.",
"suggested_fix": "Add primary keyword to introduction paragraph.",
"suggested_rewrite": append_sentence.strip(),
"patch": {
"field": "body_append",
"value": append_sentence,
},
}
)
if "](/" not in body_markdown and "](/" not in body_markdown:
issues.append(
{
"id": "internal-link",
"severity": "MEDIUM",
"location": "body_markdown",
"message": "No internal links found in the draft.",
"suggested_fix": "Add at least one internal link to a related page.",
"suggested_rewrite": "Add [related guide](/guides/related-topic) in the intro section.",
"patch": {},
}
)
headings = re.findall(r"^##\s+(.+)$", body_markdown, flags=re.MULTILINE)
duplicate = _first_duplicate(headings)
if duplicate:
issues.append(
{
"id": "duplicate-h2",
"severity": "LOW",
"location": "body_markdown",
"message": f"Duplicate H2 heading detected: {duplicate}",
"suggested_fix": "Rename duplicate headings to keep a clear document structure.",
"suggested_rewrite": None,
"patch": {},
}
)
issues.sort(key=lambda item: str(item["id"]))
return issues
def _build_language_issues(
*,
article_id: UUID,
body_markdown: str,
rules: dict[str, object],
) -> list[dict[str, object]]:
del article_id
issues: list[dict[str, object]] = []
max_sentence_words = int(rules.get("max_sentence_words", 24))
first_long_sentence = _first_sentence_longer_than(body_markdown, max_sentence_words)
if first_long_sentence:
rewrite = _shorten_sentence(first_long_sentence, max_words=max_sentence_words)
issues.append(
{
"id": "long-sentence",
"severity": "MEDIUM",
"location": "body_markdown",
"message": f"Sentence exceeds {max_sentence_words} words.",
"suggested_fix": "Split or shorten long sentence.",
"suggested_rewrite": rewrite,
"patch": {
"field": "body_replace",
"target": first_long_sentence,
"value": rewrite,
},
}
)
forbidden_phrases = rules.get("forbidden_phrases", [])
if isinstance(forbidden_phrases, list):
lower_body = body_markdown.lower()
for phrase in [str(item) for item in forbidden_phrases]:
if phrase.lower() in lower_body:
issues.append(
{
"id": f"forbidden-{_slugify(phrase)}",
"severity": "HIGH",
"location": "body_markdown",
"message": f"Forbidden phrase found: '{phrase}'.",
"suggested_fix": "Replace phrase with neutral wording.",
"suggested_rewrite": "Replace with measured, evidence-based language.",
"patch": {},
}
)
break
tone = str(rules.get("brand_tone", "neutral"))
if tone and tone.lower() in {"strict", "formal"} and "!" in body_markdown:
issues.append(
{
"id": "tone-exclamation",
"severity": "LOW",
"location": "body_markdown",
"message": "Exclamation marks conflict with strict/formal tone.",
"suggested_fix": "Use neutral punctuation.",
"suggested_rewrite": None,
"patch": {},
}
)
issues.sort(key=lambda item: str(item["id"]))
return issues
def _upsert_report_suggestions(
repository: object,
*,
article_id: UUID,
report_id: UUID,
review_kind: ContentReviewKind,
issues: list[dict[str, object]],
timestamp: datetime,
) -> None:
for issue in issues:
suggestion_id = _deterministic_suggestion_id(
article_id=article_id,
review_kind=review_kind,
suggestion_key=str(issue["id"]),
)
repository.content_reviews.upsert_suggestion(
suggestion_id=suggestion_id,
article_id=article_id,
review_kind=review_kind,
report_id=report_id,
suggestion_key=str(issue["id"]),
severity=str(issue["severity"]),
location=str(issue["location"]),
message=str(issue["message"]),
suggested_fix=_optional_string(issue.get("suggested_fix")),
suggested_rewrite=_optional_string(issue.get("suggested_rewrite")),
patch=issue.get("patch", {}) if isinstance(issue.get("patch"), dict) else {},
created_at=timestamp,
updated_at=timestamp,
)
def _deterministic_suggestion_id(
*,
article_id: UUID,
review_kind: ContentReviewKind,
suggestion_key: str,
) -> UUID:
return uuid5(
NAMESPACE_URL,
f"{article_id}:{review_kind.value}:{suggestion_key}",
)
def _apply_suggestion_to_draft(
repository: object,
*,
article_id: UUID,
suggestion: ContentReviewSuggestionSummary,
override_text: str | None,
) -> DraftSummary | None:
request = _build_draft_update_request(
repository,
article_id=article_id,
suggestion=suggestion,
override_text=override_text,
)
if request is None:
return None
base = _require_latest_draft(repository, article_id=article_id)
return update_draft(
repository,
article_id=article_id,
draft_id=base.id,
request=request,
).draft
def _build_draft_update_request(
repository: object,
*,
article_id: UUID,
suggestion: ContentReviewSuggestionSummary,
override_text: str | None,
) -> DraftUpdateRequest | None:
patch = suggestion.patch
if not isinstance(patch, dict) or not patch:
return None
base = _require_latest_draft(repository, article_id=article_id)
field = _optional_string(patch.get("field"))
if field == "meta_title":
value = override_text or _optional_string(patch.get("value"))
if value and value != (base.meta_title or ""):
return DraftUpdateRequest(meta_title=value)
return None
if field == "meta_description":
value = override_text or _optional_string(patch.get("value"))
if value and value != (base.meta_description or ""):
return DraftUpdateRequest(meta_description=value)
return None
if field == "title":
value = override_text or _optional_string(patch.get("value"))
if value and value != base.title:
return DraftUpdateRequest(title=value)
return None
if field == "body_append":
value = override_text or _optional_string(patch.get("value"))
if value:
return DraftUpdateRequest(body_markdown=f"{base.body_markdown}{value}")
return None
if field == "body_replace":
target = _optional_string(patch.get("target"))
value = override_text or _optional_string(patch.get("value"))
if target and value and target in base.body_markdown:
return DraftUpdateRequest(body_markdown=base.body_markdown.replace(target, value, 1))
return None
def _mark_review_ready(
repository: object,
*,
article_id: UUID,
event_type: str,
count: int,
) -> None:
article = repository.articles.get(article_id)
now = _now()
if article.status != ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY:
repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type=event_type,
from_status=article.status,
to_status=ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
actor_user_id=None,
payload={"issue_count": count},
created_at=now,
)
def _first_sentence_longer_than(value: str, max_words: int) -> str | None:
plain = re.sub(r"\[[^\]]+\]\([^)]+\)", "link", value)
plain = re.sub(r"^#+\s+", "", plain, flags=re.MULTILINE)
sentences = re.split(r"[.!?]\s+", plain)
for sentence in sentences:
words = [item for item in sentence.strip().split() if item]
if len(words) > max_words:
return sentence.strip()
return None
def _shorten_sentence(sentence: str, *, max_words: int) -> str:
words = [item for item in sentence.split() if item]
if len(words) <= max_words:
return sentence.strip()
return " ".join(words[: max_words - 2] + ["with", "evidence."])
def _trim(value: str, *, min_len: int, max_len: int) -> str:
cleaned = " ".join(value.split())
if len(cleaned) > max_len:
return cleaned[: max_len - 1].rstrip() + "…"
if len(cleaned) < min_len:
suffix = " SEO guidance"
while len(cleaned) < min_len:
cleaned = f"{cleaned}{suffix}"
return cleaned[:max_len]
return cleaned
def _first_duplicate(values: list[str]) -> str | None:
seen: set[str] = set()
for value in values:
key = value.strip().lower()
if key in seen:
return value.strip()
seen.add(key)
return None
def _recommended_title(*, title: str, primary_keyword: str) -> str:
if primary_keyword and primary_keyword.lower() not in title.lower():
return _trim(f"{title}: {primary_keyword} guide", min_len=30, max_len=60)
return _trim(title, min_len=30, max_len=60)
def _slugify(value: str) -> str:
normalized = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return normalized or "draft"
def _optional_string(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _now() -> datetime:
return datetime.now(UTC)
@@ -3,6 +3,7 @@ from .enums import (
AgentJobStatus,
AgentJobType,
ArticleWorkflowStatus,
ContentReviewKind,
AssetStatus,
AssetType,
ClaimRiskLevel,
@@ -10,6 +11,7 @@ from .enums import (
PlanReviewStatus,
PublishingStatus,
ReviewStatus,
ReviewSuggestionStatus,
ReviewType,
Role,
ScriptConfigVersionStatus,
@@ -47,6 +49,9 @@ from .models import (
DraftResponse,
DraftSummary,
DraftUpdateRequest,
ContentReviewIssueSummary,
ContentReviewReportSummary,
ContentReviewSuggestionSummary,
EvidenceSummary,
EvidenceCreateRequest,
EvidenceMatrixResponse,
@@ -66,7 +71,15 @@ from .models import (
ResearchListResponse,
ResearchStartResponse,
ReviewActionResponse,
ReviewSuggestionActionResponse,
ReviewSuggestionEditRequest,
ReviewSummary,
SeoReviewRunResponse,
SeoReviewReportResponse,
LanguageReviewRunResponse,
LanguageReviewReportResponse,
FinalReviewIssueSummary,
FinalReviewIssuesResponse,
RunnerFileRef,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse,
@@ -121,6 +134,10 @@ __all__ = [
"ClaimSupportStatus",
"CurrentUser",
"CurrentUserResponse",
"ContentReviewIssueSummary",
"ContentReviewKind",
"ContentReviewReportSummary",
"ContentReviewSuggestionSummary",
"DraftAssembleResponse",
"DraftFaqItem",
"DraftListResponse",
@@ -132,6 +149,10 @@ __all__ = [
"EvidenceMatrixResponse",
"EvidenceResponse",
"EvidenceUpdateRequest",
"FinalReviewIssueSummary",
"FinalReviewIssuesResponse",
"LanguageReviewRunResponse",
"LanguageReviewReportResponse",
"PlanReviewStatus",
"PlanListResponse",
"PlanResponse",
@@ -149,10 +170,15 @@ __all__ = [
"ResearchStartResponse",
"ReviewActionResponse",
"ReviewStatus",
"ReviewSuggestionActionResponse",
"ReviewSuggestionEditRequest",
"ReviewSuggestionStatus",
"ReviewSummary",
"ReviewType",
"Role",
"RunnerFileRef",
"SeoReviewRunResponse",
"SeoReviewReportResponse",
"ScriptConfigVersionCreateRequest",
"ScriptConfigVersionListResponse",
"ScriptConfigVersionResponse",
@@ -123,6 +123,18 @@ class ReviewType(str, Enum):
FINAL = "FINAL"
class ContentReviewKind(str, Enum):
SEO = "SEO"
LANGUAGE = "LANGUAGE"
class ReviewSuggestionStatus(str, Enum):
PENDING = "PENDING"
ACCEPTED = "ACCEPTED"
REJECTED = "REJECTED"
EDITED = "EDITED"
class ScriptConfigVersionStatus(str, Enum):
DRAFT = "DRAFT"
ACTIVE = "ACTIVE"
@@ -11,6 +11,7 @@ from .enums import (
AgentJobStatus,
AgentJobType,
ArticleWorkflowStatus,
ContentReviewKind,
AssetStatus,
AssetType,
ClaimRiskLevel,
@@ -18,6 +19,7 @@ from .enums import (
PlanReviewStatus,
PublishingStatus,
ReviewStatus,
ReviewSuggestionStatus,
ReviewType,
Role,
ScriptConfigVersionStatus,
@@ -513,6 +515,89 @@ class DraftUpdateRequest(ContractModel):
unsupported_claim_warnings: list[str] | None = None
class ContentReviewIssueSummary(ContractModel):
id: str = Field(min_length=1)
suggestion_id: UUID
severity: str = Field(min_length=1)
location: str = Field(min_length=1)
message: str = Field(min_length=1)
suggested_fix: str | None = None
suggested_rewrite: str | None = None
status: ReviewSuggestionStatus
class ContentReviewReportSummary(ContractModel):
id: UUID
article_id: UUID
review_kind: ContentReviewKind
draft_id: UUID
score: int = Field(ge=0, le=100)
issues: list[ContentReviewIssueSummary] = Field(default_factory=list)
recommended_slug: str = Field(min_length=1)
recommended_title: str = Field(min_length=1)
schema_json: JsonObject = Field(default_factory=dict)
rules_snapshot: JsonObject = Field(default_factory=dict)
created_at: datetime
class ContentReviewSuggestionSummary(ContractModel):
id: UUID
article_id: UUID
review_kind: ContentReviewKind
report_id: UUID
suggestion_key: str = Field(min_length=1)
severity: str = Field(min_length=1)
location: str = Field(min_length=1)
message: str = Field(min_length=1)
suggested_fix: str | None = None
suggested_rewrite: str | None = None
patch: JsonObject = Field(default_factory=dict)
status: ReviewSuggestionStatus
applied_text: str | None = None
created_at: datetime
updated_at: datetime
class SeoReviewRunResponse(ContractModel):
report: ContentReviewReportSummary
class SeoReviewReportResponse(ContractModel):
report: ContentReviewReportSummary
class LanguageReviewRunResponse(ContractModel):
report: ContentReviewReportSummary
class LanguageReviewReportResponse(ContractModel):
report: ContentReviewReportSummary
class ReviewSuggestionActionResponse(ContractModel):
suggestion: ContentReviewSuggestionSummary
draft: DraftSummary | None = None
class ReviewSuggestionEditRequest(ContractModel):
applied_text: str = Field(min_length=1)
class FinalReviewIssueSummary(ContractModel):
review_kind: ContentReviewKind
suggestion_id: UUID
severity: str = Field(min_length=1)
location: str = Field(min_length=1)
message: str = Field(min_length=1)
status: ReviewSuggestionStatus
class FinalReviewIssuesResponse(ContractModel):
article_id: UUID
unresolved_count: int = Field(ge=0)
unresolved_issues: list[FinalReviewIssueSummary] = Field(default_factory=list)
class AssetGenerateSpecsResponse(ContractModel):
assets: list[AssetSummary] = Field(default_factory=list)
@@ -10,6 +10,7 @@ from .enums import (
AgentJobStatus,
AgentJobType,
ArticleWorkflowStatus,
ContentReviewKind,
AssetStatus,
AssetType,
ClaimRiskLevel,
@@ -17,6 +18,7 @@ from .enums import (
PlanReviewStatus,
PublishingStatus,
ReviewStatus,
ReviewSuggestionStatus,
ReviewType,
Role,
ScriptConfigVersionStatus,
@@ -53,6 +55,9 @@ from .models import (
DraftResponse,
DraftSummary,
DraftUpdateRequest,
ContentReviewIssueSummary,
ContentReviewReportSummary,
ContentReviewSuggestionSummary,
EvidenceSummary,
EvidenceCreateRequest,
EvidenceMatrixResponse,
@@ -71,8 +76,16 @@ from .models import (
ResearchArtifactSummary,
ResearchListResponse,
ResearchStartResponse,
FinalReviewIssueSummary,
FinalReviewIssuesResponse,
LanguageReviewReportResponse,
LanguageReviewRunResponse,
ReviewSuggestionActionResponse,
ReviewSuggestionEditRequest,
ReviewActionResponse,
ReviewSummary,
SeoReviewReportResponse,
SeoReviewRunResponse,
RunnerFileRef,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse,
@@ -100,7 +113,9 @@ CONTRACT_ENUMS: tuple[type[Enum], ...] = (
AssetType,
PlanReviewStatus,
ReviewStatus,
ReviewSuggestionStatus,
ReviewType,
ContentReviewKind,
ScriptConfigVersionStatus,
)
@@ -149,6 +164,13 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
DraftListResponse,
DraftResponse,
DraftUpdateRequest,
ContentReviewIssueSummary,
ContentReviewReportSummary,
ContentReviewSuggestionSummary,
FinalReviewIssueSummary,
FinalReviewIssuesResponse,
LanguageReviewReportResponse,
LanguageReviewRunResponse,
AssetGenerateSpecsResponse,
AssetListResponse,
AssetResponse,
@@ -159,6 +181,10 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
AssetUploadResponse,
ReviewSummary,
ReviewActionResponse,
ReviewSuggestionActionResponse,
ReviewSuggestionEditRequest,
SeoReviewReportResponse,
SeoReviewRunResponse,
PublishCommitSummary,
RunnerFileRef,
AgentJobSummary,
+4
View File
@@ -11,6 +11,8 @@ PLAN_SECTIONS_TABLE = "plan_sections"
EVIDENCE_ITEMS_TABLE = "evidence_items"
CLAIMS_TABLE = "claims"
ARTICLE_DRAFTS_TABLE = "article_drafts"
ARTICLE_REVIEW_REPORTS_TABLE = "article_review_reports"
ARTICLE_REVIEW_SUGGESTIONS_TABLE = "article_review_suggestions"
ASSETS_TABLE = "assets"
ASSET_REVISIONS_TABLE = "asset_revisions"
WORKFLOW_EVENTS_TABLE = "workflow_events"
@@ -31,6 +33,8 @@ CORE_TABLES: tuple[str, ...] = (
EVIDENCE_ITEMS_TABLE,
CLAIMS_TABLE,
ARTICLE_DRAFTS_TABLE,
ARTICLE_REVIEW_REPORTS_TABLE,
ARTICLE_REVIEW_SUGGESTIONS_TABLE,
ASSETS_TABLE,
ASSET_REVISIONS_TABLE,
WORKFLOW_EVENTS_TABLE,
@@ -13,6 +13,10 @@ from src.domain.contracts import (
AgentJobStatus,
AgentJobSummary,
AgentJobType,
ContentReviewKind,
ContentReviewIssueSummary,
ContentReviewReportSummary,
ContentReviewSuggestionSummary,
AssetRevisionSummary,
AssetStatus,
AssetSummary,
@@ -33,6 +37,7 @@ from src.domain.contracts import (
ResearchArtifactManifestSummary,
ResearchArtifactSummary,
ArticleWorkflowStatus,
ReviewSuggestionStatus,
WorkflowEventSummary,
Role,
ScriptConfigVersionStatus,
@@ -59,6 +64,7 @@ class BackendRepository:
self.boundary_questions = BoundaryQuestionsRepository(self)
self.article_plans = ArticlePlansRepository(self)
self.article_drafts = ArticleDraftsRepository(self)
self.content_reviews = ContentReviewsRepository(self)
self.assets = AssetsRepository(self)
self.research_manifests = ResearchManifestsRepository(self)
self.evidence_items = EvidenceItemsRepository(self)
@@ -1197,6 +1203,418 @@ class ArticleDraftsRepository:
"""
class ContentReviewsRepository:
def __init__(self, repository: BackendRepository) -> None:
self._repository = repository
def create_report(
self,
*,
article_id: UUID,
review_kind: ContentReviewKind,
draft_id: UUID,
score: int,
recommended_slug: str,
recommended_title: str,
schema_json: JsonObject,
rules_snapshot: JsonObject,
created_at: datetime,
) -> ContentReviewReportSummary:
report_id = uuid4()
placeholder = self._repository.placeholder()
json_cast = self._repository.json_cast()
with self._repository.connection() as connection:
connection.execute(
f"""
INSERT INTO article_review_reports (
id,
article_id,
review_kind,
draft_id,
score,
issues,
recommended_slug,
recommended_title,
schema_json,
rules_snapshot,
created_at
)
VALUES (
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder}{json_cast},
{placeholder},
{placeholder},
{placeholder}{json_cast},
{placeholder}{json_cast},
{placeholder}
)
""",
(
str(report_id),
str(article_id),
review_kind.value,
str(draft_id),
score,
_json_value([]),
recommended_slug,
recommended_title,
_json_value(schema_json),
_json_value(rules_snapshot),
_datetime_value(created_at),
),
)
return self.get_report(article_id=article_id, report_id=report_id)
def list_reports_for_article(
self,
*,
article_id: UUID,
review_kind: ContentReviewKind,
) -> list[ContentReviewReportSummary]:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
rows = connection.execute(
f"""
SELECT
id,
article_id,
review_kind,
draft_id,
score,
recommended_slug,
recommended_title,
schema_json,
rules_snapshot,
created_at
FROM article_review_reports
WHERE article_id = {placeholder}
AND review_kind = {placeholder}
ORDER BY created_at DESC
""",
(str(article_id), review_kind.value),
).fetchall()
return [self.get_report(article_id=article_id, report_id=_row_value(row, "id")) for row in rows]
def latest_report(
self,
*,
article_id: UUID,
review_kind: ContentReviewKind,
) -> ContentReviewReportSummary | None:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT id
FROM article_review_reports
WHERE article_id = {placeholder}
AND review_kind = {placeholder}
ORDER BY created_at DESC
LIMIT 1
""",
(str(article_id), review_kind.value),
).fetchone()
if row is None:
return None
return self.get_report(article_id=article_id, report_id=_row_value(row, "id"))
def get_report(self, *, article_id: UUID, report_id: UUID) -> ContentReviewReportSummary:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT
id,
article_id,
review_kind,
draft_id,
score,
recommended_slug,
recommended_title,
schema_json,
rules_snapshot,
created_at
FROM article_review_reports
WHERE article_id = {placeholder}
AND id = {placeholder}
""",
(str(article_id), str(report_id)),
).fetchone()
if row is None:
raise LookupError(f"Content review report not found: {report_id}")
suggestions = self.list_suggestions_for_report(
article_id=article_id,
report_id=report_id,
)
issues = [
ContentReviewIssueSummary(
id=item.suggestion_key,
suggestion_id=item.id,
severity=item.severity,
location=item.location,
message=item.message,
suggested_fix=item.suggested_fix,
suggested_rewrite=item.suggested_rewrite,
status=item.status,
)
for item in suggestions
]
return _content_review_report_from_row(row, issues=issues)
def upsert_suggestion(
self,
*,
suggestion_id: UUID,
article_id: UUID,
review_kind: ContentReviewKind,
report_id: UUID,
suggestion_key: str,
severity: str,
location: str,
message: str,
suggested_fix: str | None,
suggested_rewrite: str | None,
patch: JsonObject,
created_at: datetime,
updated_at: datetime,
) -> ContentReviewSuggestionSummary:
placeholder = self._repository.placeholder()
json_cast = self._repository.json_cast()
with self._repository.connection() as connection:
connection.execute(
f"""
INSERT INTO article_review_suggestions (
id,
article_id,
review_kind,
report_id,
suggestion_key,
severity,
location,
message,
suggested_fix,
suggested_rewrite,
patch,
status,
applied_text,
created_at,
updated_at
)
VALUES (
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder}{json_cast},
{placeholder},
{placeholder},
{placeholder},
{placeholder}
)
ON CONFLICT (article_id, review_kind, suggestion_key) DO UPDATE SET
report_id = excluded.report_id,
severity = excluded.severity,
location = excluded.location,
message = excluded.message,
suggested_fix = excluded.suggested_fix,
suggested_rewrite = excluded.suggested_rewrite,
patch = excluded.patch,
status = CASE
WHEN article_review_suggestions.status = 'PENDING' THEN excluded.status
ELSE article_review_suggestions.status
END,
applied_text = CASE
WHEN article_review_suggestions.status = 'PENDING' THEN excluded.applied_text
ELSE article_review_suggestions.applied_text
END,
updated_at = excluded.updated_at
""",
(
str(suggestion_id),
str(article_id),
review_kind.value,
str(report_id),
suggestion_key,
severity,
location,
message,
suggested_fix,
suggested_rewrite,
_json_value(patch),
ReviewSuggestionStatus.PENDING.value,
None,
_datetime_value(created_at),
_datetime_value(updated_at),
),
)
return self.get_suggestion(
article_id=article_id,
review_kind=review_kind,
suggestion_id=suggestion_id,
)
def get_suggestion(
self,
*,
article_id: UUID,
review_kind: ContentReviewKind,
suggestion_id: UUID,
) -> ContentReviewSuggestionSummary:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT
id,
article_id,
review_kind,
report_id,
suggestion_key,
severity,
location,
message,
suggested_fix,
suggested_rewrite,
patch,
status,
applied_text,
created_at,
updated_at
FROM article_review_suggestions
WHERE article_id = {placeholder}
AND review_kind = {placeholder}
AND id = {placeholder}
""",
(
str(article_id),
review_kind.value,
str(suggestion_id),
),
).fetchone()
if row is None:
raise LookupError(f"Content review suggestion not found: {suggestion_id}")
return _content_review_suggestion_from_row(row)
def list_suggestions_for_report(
self,
*,
article_id: UUID,
report_id: UUID,
) -> list[ContentReviewSuggestionSummary]:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
rows = connection.execute(
f"""
SELECT
id,
article_id,
review_kind,
report_id,
suggestion_key,
severity,
location,
message,
suggested_fix,
suggested_rewrite,
patch,
status,
applied_text,
created_at,
updated_at
FROM article_review_suggestions
WHERE article_id = {placeholder}
AND report_id = {placeholder}
ORDER BY suggestion_key
""",
(str(article_id), str(report_id)),
).fetchall()
return [_content_review_suggestion_from_row(row) for row in rows]
def list_unresolved_for_article(
self,
*,
article_id: UUID,
) -> list[ContentReviewSuggestionSummary]:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
rows = connection.execute(
f"""
SELECT
id,
article_id,
review_kind,
report_id,
suggestion_key,
severity,
location,
message,
suggested_fix,
suggested_rewrite,
patch,
status,
applied_text,
created_at,
updated_at
FROM article_review_suggestions
WHERE article_id = {placeholder}
AND status = {placeholder}
ORDER BY review_kind, suggestion_key
""",
(str(article_id), ReviewSuggestionStatus.PENDING.value),
).fetchall()
return [_content_review_suggestion_from_row(row) for row in rows]
def update_suggestion(
self,
*,
article_id: UUID,
review_kind: ContentReviewKind,
suggestion_id: UUID,
status: ReviewSuggestionStatus,
applied_text: str | None,
updated_at: datetime,
) -> ContentReviewSuggestionSummary:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
cursor = connection.execute(
f"""
UPDATE article_review_suggestions
SET
status = {placeholder},
applied_text = {placeholder},
updated_at = {placeholder}
WHERE article_id = {placeholder}
AND review_kind = {placeholder}
AND id = {placeholder}
""",
(
status.value,
applied_text,
_datetime_value(updated_at),
str(article_id),
review_kind.value,
str(suggestion_id),
),
)
if int(getattr(cursor, "rowcount", 0)) <= 0:
raise LookupError(f"Content review suggestion not found: {suggestion_id}")
return self.get_suggestion(
article_id=article_id,
review_kind=review_kind,
suggestion_id=suggestion_id,
)
class AssetsRepository:
def __init__(self, repository: BackendRepository) -> None:
self._repository = repository
@@ -2707,6 +3125,46 @@ def _draft_summary_from_row(row: Any) -> DraftSummary:
)
def _content_review_report_from_row(
row: Any,
*,
issues: list[ContentReviewIssueSummary],
) -> ContentReviewReportSummary:
return ContentReviewReportSummary(
id=_row_value(row, "id"),
article_id=_row_value(row, "article_id"),
review_kind=_row_value(row, "review_kind"),
draft_id=_row_value(row, "draft_id"),
score=_row_value(row, "score"),
issues=issues,
recommended_slug=_row_value(row, "recommended_slug"),
recommended_title=_row_value(row, "recommended_title"),
schema_json=_json_from_row(row, "schema_json"),
rules_snapshot=_json_from_row(row, "rules_snapshot"),
created_at=_row_value(row, "created_at"),
)
def _content_review_suggestion_from_row(row: Any) -> ContentReviewSuggestionSummary:
return ContentReviewSuggestionSummary(
id=_row_value(row, "id"),
article_id=_row_value(row, "article_id"),
review_kind=_row_value(row, "review_kind"),
report_id=_row_value(row, "report_id"),
suggestion_key=_row_value(row, "suggestion_key"),
severity=_row_value(row, "severity"),
location=_row_value(row, "location"),
message=_row_value(row, "message"),
suggested_fix=_row_value(row, "suggested_fix"),
suggested_rewrite=_row_value(row, "suggested_rewrite"),
patch=_json_from_row(row, "patch"),
status=_row_value(row, "status"),
applied_text=_row_value(row, "applied_text"),
created_at=_row_value(row, "created_at"),
updated_at=_row_value(row, "updated_at"),
)
def _asset_revision_from_row(row: Any) -> AssetRevisionSummary:
return AssetRevisionSummary(
id=_row_value(row, "id"),
+86
View File
@@ -233,6 +233,41 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS evidence_references JSONB NOT NULL DEFAULT '[]'::jsonb",
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS unsupported_claim_warnings JSONB NOT NULL DEFAULT '[]'::jsonb",
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS based_on_draft_id UUID REFERENCES article_drafts(id) ON DELETE SET NULL",
"""
CREATE TABLE IF NOT EXISTS article_review_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
review_kind TEXT NOT NULL CHECK (review_kind IN ('SEO', 'LANGUAGE')),
draft_id UUID NOT NULL REFERENCES article_drafts(id) ON DELETE CASCADE,
score INTEGER NOT NULL,
issues JSONB NOT NULL DEFAULT '[]'::jsonb,
recommended_slug TEXT NOT NULL,
recommended_title TEXT NOT NULL,
schema_json JSONB NOT NULL DEFAULT '{}'::jsonb,
rules_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
"""
CREATE TABLE IF NOT EXISTS article_review_suggestions (
id UUID PRIMARY KEY,
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
review_kind TEXT NOT NULL CHECK (review_kind IN ('SEO', 'LANGUAGE')),
report_id UUID NOT NULL REFERENCES article_review_reports(id) ON DELETE CASCADE,
suggestion_key TEXT NOT NULL,
severity TEXT NOT NULL,
location TEXT NOT NULL,
message TEXT NOT NULL,
suggested_fix TEXT,
suggested_rewrite TEXT,
patch JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL CHECK (status IN ('PENDING', 'ACCEPTED', 'REJECTED', 'EDITED')),
applied_text TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (article_id, review_kind, suggestion_key)
)
""",
f"""
CREATE TABLE IF NOT EXISTS assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -401,6 +436,14 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
CREATE INDEX IF NOT EXISTS idx_script_config_version_events_site
ON script_config_version_events (target_site_id, created_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_article_review_reports_article
ON article_review_reports (article_id, review_kind, created_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_article_review_suggestions_report
ON article_review_suggestions (report_id, status)
""",
)
@@ -570,6 +613,41 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
)
""",
"""
CREATE TABLE IF NOT EXISTS article_review_reports (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
review_kind TEXT NOT NULL,
draft_id TEXT NOT NULL,
score INTEGER NOT NULL,
issues TEXT NOT NULL DEFAULT '[]',
recommended_slug TEXT NOT NULL,
recommended_title TEXT NOT NULL,
schema_json TEXT NOT NULL DEFAULT '{}',
rules_snapshot TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS article_review_suggestions (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
review_kind TEXT NOT NULL,
report_id TEXT NOT NULL,
suggestion_key TEXT NOT NULL,
severity TEXT NOT NULL,
location TEXT NOT NULL,
message TEXT NOT NULL,
suggested_fix TEXT,
suggested_rewrite TEXT,
patch TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL,
applied_text TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (article_id, review_kind, suggestion_key)
)
""",
"""
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
@@ -725,6 +803,14 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
CREATE INDEX IF NOT EXISTS idx_script_config_version_events_site
ON script_config_version_events (target_site_id, created_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_article_review_reports_article
ON article_review_reports (article_id, review_kind, created_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_article_review_suggestions_report
ON article_review_suggestions (report_id, status)
""",
)
+2
View File
@@ -16,6 +16,7 @@ from src.presentation.routes.boundary_questions import router as boundary_questi
from src.presentation.routes.drafts import router as drafts_router
from src.presentation.routes.evidence import router as evidence_router
from src.presentation.routes.plans import router as plans_router
from src.presentation.routes.reviews import router as reviews_router
from src.presentation.routes.sites import router as sites_router
@@ -27,6 +28,7 @@ app.include_router(boundary_questions_router)
app.include_router(plans_router)
app.include_router(evidence_router)
app.include_router(drafts_router)
app.include_router(reviews_router)
app.include_router(agent_jobs_router)
app.include_router(internal_agent_jobs_router)
app.include_router(sites_router)
@@ -0,0 +1,244 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from src.application.reviews import (
accept_suggestion,
edit_suggestion,
get_final_review_issues,
get_language_report,
get_seo_report,
reject_suggestion,
run_language_review,
run_seo_review,
)
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
ContentReviewKind,
CurrentUser,
FinalReviewIssuesResponse,
LanguageReviewReportResponse,
LanguageReviewRunResponse,
ReviewSuggestionActionResponse,
ReviewSuggestionEditRequest,
SeoReviewReportResponse,
SeoReviewRunResponse,
)
from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles
router = APIRouter(prefix="/api", tags=["reviews"])
@router.post(
"/articles/{article_id}/seo/review",
response_model=SeoReviewRunResponse,
status_code=status.HTTP_201_CREATED,
)
def post_run_seo_review(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> SeoReviewRunResponse:
try:
return run_seo_review(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except PermissionError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.get(
"/articles/{article_id}/seo/report",
response_model=SeoReviewReportResponse,
)
def get_article_seo_report(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> SeoReviewReportResponse:
try:
return get_seo_report(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/language/review",
response_model=LanguageReviewRunResponse,
status_code=status.HTTP_201_CREATED,
)
def post_run_language_review(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> LanguageReviewRunResponse:
try:
return run_language_review(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except PermissionError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.get(
"/articles/{article_id}/language/report",
response_model=LanguageReviewReportResponse,
)
def get_article_language_report(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> LanguageReviewReportResponse:
try:
return get_language_report(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/seo/suggestions/{suggestion_id}/accept",
response_model=ReviewSuggestionActionResponse,
)
def post_accept_seo_suggestion(
article_id: UUID,
suggestion_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ReviewSuggestionActionResponse:
try:
return accept_suggestion(
repository,
article_id=article_id,
review_kind=ContentReviewKind.SEO,
suggestion_id=suggestion_id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/seo/suggestions/{suggestion_id}/reject",
response_model=ReviewSuggestionActionResponse,
)
def post_reject_seo_suggestion(
article_id: UUID,
suggestion_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ReviewSuggestionActionResponse:
try:
return reject_suggestion(
repository,
article_id=article_id,
review_kind=ContentReviewKind.SEO,
suggestion_id=suggestion_id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/seo/suggestions/{suggestion_id}/edit",
response_model=ReviewSuggestionActionResponse,
)
def post_edit_seo_suggestion(
article_id: UUID,
suggestion_id: UUID,
request: ReviewSuggestionEditRequest,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ReviewSuggestionActionResponse:
try:
return edit_suggestion(
repository,
article_id=article_id,
review_kind=ContentReviewKind.SEO,
suggestion_id=suggestion_id,
request=request,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/language/suggestions/{suggestion_id}/accept",
response_model=ReviewSuggestionActionResponse,
)
def post_accept_language_suggestion(
article_id: UUID,
suggestion_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ReviewSuggestionActionResponse:
try:
return accept_suggestion(
repository,
article_id=article_id,
review_kind=ContentReviewKind.LANGUAGE,
suggestion_id=suggestion_id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/language/suggestions/{suggestion_id}/reject",
response_model=ReviewSuggestionActionResponse,
)
def post_reject_language_suggestion(
article_id: UUID,
suggestion_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ReviewSuggestionActionResponse:
try:
return reject_suggestion(
repository,
article_id=article_id,
review_kind=ContentReviewKind.LANGUAGE,
suggestion_id=suggestion_id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/language/suggestions/{suggestion_id}/edit",
response_model=ReviewSuggestionActionResponse,
)
def post_edit_language_suggestion(
article_id: UUID,
suggestion_id: UUID,
request: ReviewSuggestionEditRequest,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ReviewSuggestionActionResponse:
try:
return edit_suggestion(
repository,
article_id=article_id,
review_kind=ContentReviewKind.LANGUAGE,
suggestion_id=suggestion_id,
request=request,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.get(
"/articles/{article_id}/final-review/issues",
response_model=FinalReviewIssuesResponse,
)
def get_article_final_review_issues(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> FinalReviewIssuesResponse:
try:
return get_final_review_issues(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@@ -0,0 +1,431 @@
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi.testclient import TestClient
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
from src.application.seed_data import seed_reference_data # noqa: E402
from src.infrastructure.repositories import open_backend_repository # noqa: E402
from src.presentation.dependencies import get_repository # noqa: E402
from src.presentation.main import app # noqa: E402
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_ADMIN_EMAIL = "admin@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class SeoLanguageReviewPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects")
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'seo-language-review.db'}"
self.repository = open_backend_repository(dsn)
self.repository.setup()
seed_reference_data(self.repository)
app.dependency_overrides[get_repository] = lambda: self.repository
self.client = TestClient(app)
def tearDown(self) -> None:
app.dependency_overrides.clear()
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
self.tmp_dir.cleanup()
def test_seo_report_includes_required_fields(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
self._run_seo_review(article_id)
report = self._get_seo_report(article_id)
self.assertIn("score", report)
self.assertIn("issues", report)
self.assertIn("recommended_slug", report)
self.assertIn("recommended_title", report)
self.assertIn("schema_json", report)
self.assertGreaterEqual(len(report["issues"]), 1)
first_issue = report["issues"][0]
self.assertIn("severity", first_issue)
self.assertIn("location", first_issue)
self.assertIn("message", first_issue)
self.assertIn("suggested_fix", first_issue)
self.assertIn("suggestion_id", first_issue)
def test_language_report_includes_required_fields(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
self._seed_language_problematic_draft(article_id)
self._run_language_review(article_id)
report = self._get_language_report(article_id)
self.assertGreaterEqual(len(report["issues"]), 1)
self.assertTrue(any(issue.get("suggested_rewrite") for issue in report["issues"]))
first_issue = report["issues"][0]
self.assertIn("severity", first_issue)
self.assertIn("location", first_issue)
self.assertIn("message", first_issue)
self.assertIn("suggested_rewrite", first_issue)
def test_editor_actions_accept_reject_edit_and_immutable_drafts(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
self._seed_language_problematic_draft(article_id)
self._run_seo_review(article_id)
self._run_language_review(article_id)
seo_report = self._get_seo_report(article_id)
language_report = self._get_language_report(article_id)
base_latest = self._latest_draft(article_id)
seo_patch_issue = next(
issue for issue in seo_report["issues"] if issue["location"] == "meta_description"
)
accept_response = self.client.post(
f"/api/articles/{article_id}/seo/suggestions/{seo_patch_issue['suggestion_id']}/accept",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, accept_response.status_code, accept_response.text)
accepted = accept_response.json()
self.assertEqual("ACCEPTED", accepted["suggestion"]["status"])
self.assertEqual(base_latest["version"] + 1, accepted["draft"]["version"])
seo_non_patch_issue = next(
issue for issue in seo_report["issues"] if issue["location"] == "body_markdown"
)
reject_response = self.client.post(
f"/api/articles/{article_id}/seo/suggestions/{seo_non_patch_issue['suggestion_id']}/reject",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, reject_response.status_code, reject_response.text)
self.assertEqual("REJECTED", reject_response.json()["suggestion"]["status"])
self.assertIsNone(reject_response.json()["draft"])
language_rewrite_issue = next(
issue for issue in language_report["issues"] if "Sentence exceeds" in issue["message"]
)
edit_response = self.client.post(
f"/api/articles/{article_id}/language/suggestions/{language_rewrite_issue['suggestion_id']}/edit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"applied_text": "This sentence now states the same idea clearly."},
)
self.assertEqual(200, edit_response.status_code, edit_response.text)
edited = edit_response.json()
self.assertEqual("EDITED", edited["suggestion"]["status"])
self.assertEqual(
accepted["draft"]["version"] + 1,
edited["draft"]["version"],
)
self.assertIn("states the same idea clearly", edited["draft"]["body_markdown"])
def test_target_site_overrides_are_applied(self) -> None:
site = self.client.get(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()[0]["site"]
patch_response = self.client.patch(
f"/api/sites/{site['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json={
"seo_rules": {
"seo_review": {
"meta_description_min": 10,
"meta_description_max": 40,
"schema_type": "HowTo",
},
"language_review": {
"max_sentence_words": 8,
"forbidden_phrases": ["precision tone"],
"brand_tone": "strict",
},
}
},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
article_id = self._prepare_article_with_assembled_draft(target_site_id=UUID(site["id"]))
self._seed_language_problematic_draft(
article_id,
extra_body="This precision tone phrase should be avoided!",
)
self._run_seo_review(article_id)
self._run_language_review(article_id)
seo_report = self._get_seo_report(article_id)
self.assertEqual("HowTo", seo_report["schema_json"]["@type"])
language_report = self._get_language_report(article_id)
self.assertEqual(8, language_report["rules_snapshot"]["max_sentence_words"])
self.assertEqual("strict", language_report["rules_snapshot"]["brand_tone"])
self.assertTrue(
any("8 words" in issue["message"] for issue in language_report["issues"])
)
self.assertTrue(
any("Forbidden phrase" in issue["message"] for issue in language_report["issues"])
)
def test_final_review_shows_unresolved_issues(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
self._seed_language_problematic_draft(article_id)
self._run_seo_review(article_id)
self._run_language_review(article_id)
seo_report = self._get_seo_report(article_id)
issue_to_accept = seo_report["issues"][0]
accept_response = self.client.post(
f"/api/articles/{article_id}/seo/suggestions/{issue_to_accept['suggestion_id']}/accept",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, accept_response.status_code, accept_response.text)
unresolved_response = self.client.get(
f"/api/articles/{article_id}/final-review/issues",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, unresolved_response.status_code, unresolved_response.text)
unresolved = unresolved_response.json()
self.assertGreaterEqual(unresolved["unresolved_count"], 1)
self.assertTrue(unresolved["unresolved_issues"])
self.assertTrue(
all(item["status"] == "PENDING" for item in unresolved["unresolved_issues"])
)
def test_fake_runner_is_deterministic(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
self._seed_language_problematic_draft(article_id)
self._run_seo_review(article_id)
first = self._get_seo_report(article_id)
self._run_seo_review(article_id)
second = self._get_seo_report(article_id)
self.assertEqual(first["score"], second["score"])
self.assertEqual(first["recommended_slug"], second["recommended_slug"])
self.assertEqual(first["recommended_title"], second["recommended_title"])
first_issue_signature = [
(
issue["id"],
issue["severity"],
issue["location"],
issue["message"],
issue["suggested_fix"],
issue["suggested_rewrite"],
)
for issue in first["issues"]
]
second_issue_signature = [
(
issue["id"],
issue["severity"],
issue["location"],
issue["message"],
issue["suggested_fix"],
issue["suggested_rewrite"],
)
for issue in second["issues"]
]
self.assertEqual(first_issue_signature, second_issue_signature)
def _run_seo_review(self, article_id: UUID) -> dict[str, Any]:
response = self.client.post(
f"/api/articles/{article_id}/seo/review",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, response.status_code, response.text)
return response.json()["report"]
def _run_language_review(self, article_id: UUID) -> dict[str, Any]:
response = self.client.post(
f"/api/articles/{article_id}/language/review",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, response.status_code, response.text)
return response.json()["report"]
def _get_seo_report(self, article_id: UUID) -> dict[str, Any]:
response = self.client.get(
f"/api/articles/{article_id}/seo/report",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["report"]
def _get_language_report(self, article_id: UUID) -> dict[str, Any]:
response = self.client.get(
f"/api/articles/{article_id}/language/report",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["report"]
def _latest_draft(self, article_id: UUID) -> dict[str, Any]:
response = self.client.get(
f"/api/articles/{article_id}/drafts",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["drafts"][0]
def _seed_language_problematic_draft(
self,
article_id: UUID,
*,
extra_body: str = "",
) -> None:
latest = self._latest_draft(article_id)
body = "\n".join(
[
latest["body_markdown"],
(
"This sentence is intentionally extremely long and includes many additional words "
"that make it exceed any practical editorial threshold for readability while still "
"describing the same guidance in an unnecessarily verbose and repetitive way for testing."
),
"Our approach is the best ever for everyone.",
extra_body,
]
)
patch_response = self.client.patch(
f"/api/articles/{article_id}/drafts/{latest['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"body_markdown": body},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
def _prepare_article_with_assembled_draft(
self,
*,
target_site_id: UUID | None = None,
) -> UUID:
article_id, _ = self._prepare_article_with_approved_plan(target_site_id=target_site_id)
self._ensure_evidence_ready(article_id)
section_jobs = self._start_parallel_production(article_id)
self._complete_all_section_jobs_success(section_jobs)
self._assemble_draft(article_id)
return article_id
def _prepare_article_with_approved_plan(
self,
*,
target_site_id: UUID | None = None,
) -> tuple[UUID, dict[str, Any]]:
if target_site_id is None:
site = self.client.get(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()[0]["site"]
target_site_id = UUID(site["id"])
article = self.client.post(
"/api/articles",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"target_site_id": str(target_site_id),
"brief_description": "SEO and language review implementation coverage.",
"working_title": "Short",
"content_type": "longform_guide",
"primary_keyword": "seo language review",
},
).json()["article"]
article_id = UUID(article["id"])
questions = self.client.post(
f"/api/articles/{article_id}/boundary-questions/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["questions"]
for question in questions:
if question["is_required"]:
patch_response = self.client.patch(
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"answer": f"Answer for {question['category']}"},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
submit_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/submit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, submit_response.status_code, submit_response.text)
plan_response = self.client.post(
f"/api/articles/{article_id}/plan/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, plan_response.status_code, plan_response.text)
plan = plan_response.json()["plan"]
approve_plan_response = self.client.post(
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, approve_plan_response.status_code, approve_plan_response.text)
return article_id, plan
def _ensure_evidence_ready(self, article_id: UUID) -> None:
research_response = self.client.post(
f"/api/articles/{article_id}/research/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, research_response.status_code, research_response.text)
evidence_response = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
def _start_parallel_production(self, article_id: UUID) -> list[dict[str, Any]]:
response = self.client.post(
f"/api/articles/{article_id}/draft/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(202, response.status_code, response.text)
jobs = response.json()["jobs"]
section_jobs = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"]
self.assertTrue(section_jobs)
return section_jobs
def _complete_all_section_jobs_success(self, section_jobs: list[dict[str, Any]]) -> None:
for index, job in enumerate(section_jobs, start=1):
complete_response = self.client.post(
f"/internal/agent-jobs/{job['id']}/complete",
json={
"workspace_path": f"/tmp/{job['id']}",
"stdout": "fake section scaffolding runner\n",
"stderr": "",
"exit_code": 0,
"duration_ms": 1,
"output": {
"status": "SUCCEEDED",
"output_files": [{"path": f"outputs/section-{index}.md"}],
"payload": {
"used_evidence_ids": job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": f"## {job['payload']['heading']}\n\nSection {index}.",
},
},
},
)
self.assertEqual(200, complete_response.status_code, complete_response.text)
self.assertEqual("SUCCEEDED", complete_response.json()["job"]["status"])
def _assemble_draft(self, article_id: UUID) -> dict[str, Any]:
response = self.client.post(
f"/api/articles/{article_id}/draft/assemble",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, response.status_code, response.text)
return response.json()["draft"]
if __name__ == "__main__":
unittest.main()
+14
View File
@@ -348,6 +348,10 @@ main {
margin-top: 28px;
}
.reviewPanel {
margin-top: 28px;
}
.draftEditor .detailGrid select {
width: 100%;
border-radius: 6px;
@@ -382,6 +386,16 @@ main {
font-size: 12px;
}
.reviewActions {
display: grid;
grid-template-columns: repeat(2, minmax(88px, 1fr));
gap: 6px;
}
.reviewActions input {
grid-column: 1 / 3;
}
@media (max-width: 640px) {
main {
padding: 28px;
@@ -0,0 +1,64 @@
import type {
ContentReviewIssueSummary,
ContentReviewReportSummary,
ContentReviewKind,
FinalReviewIssuesResponse,
} from "@pipeline/shared";
export type ReviewIssueRow = {
reviewKind: ContentReviewKind;
issueId: string;
suggestionId: string;
severity: string;
location: string;
message: string;
suggestedFix: string;
suggestedRewrite: string;
status: string;
};
export type ReviewSummary = {
unresolvedCount: number;
unresolvedTitles: string[];
};
export function buildReviewIssueRows(
seoReport: ContentReviewReportSummary | null,
languageReport: ContentReviewReportSummary | null,
): ReviewIssueRow[] {
return [
...mapIssues("SEO", seoReport?.issues ?? []),
...mapIssues("LANGUAGE", languageReport?.issues ?? []),
];
}
export function buildFinalReviewSummary(
response: FinalReviewIssuesResponse | null,
): ReviewSummary {
if (!response) {
return { unresolvedCount: 0, unresolvedTitles: [] };
}
return {
unresolvedCount: response.unresolved_count,
unresolvedTitles: (response.unresolved_issues ?? []).map(
(issue) => `${issue.review_kind}: ${issue.message}`,
),
};
}
function mapIssues(
reviewKind: ContentReviewKind,
issues: readonly ContentReviewIssueSummary[],
): ReviewIssueRow[] {
return issues.map((issue) => ({
reviewKind,
issueId: issue.id,
suggestionId: issue.suggestion_id,
severity: issue.severity,
location: issue.location,
message: issue.message,
suggestedFix: issue.suggested_fix ?? "",
suggestedRewrite: issue.suggested_rewrite ?? "",
status: issue.status,
}));
}
@@ -0,0 +1,245 @@
"use client";
import { useMemo, useState } from "react";
import type {
ContentReviewReportSummary,
FinalReviewIssuesResponse,
} from "@pipeline/shared";
import {
ApiError,
acceptLanguageSuggestion,
acceptSeoSuggestion,
editLanguageSuggestion,
editSeoSuggestion,
fetchFinalReviewIssues,
fetchLanguageReport,
fetchSeoReport,
rejectLanguageSuggestion,
rejectSeoSuggestion,
runLanguageReview,
runSeoReview,
} from "@/shared/pipeline-api";
import { buildFinalReviewSummary, buildReviewIssueRows } from "./model";
type ArticleReviewPanelProps = {
articleId: string;
};
export function ArticleReviewPanel({ articleId }: ArticleReviewPanelProps) {
const [seoReport, setSeoReport] = useState<ContentReviewReportSummary | null>(null);
const [languageReport, setLanguageReport] = useState<ContentReviewReportSummary | null>(null);
const [finalIssues, setFinalIssues] = useState<FinalReviewIssuesResponse | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
const [message, setMessage] = useState("");
const [edits, setEdits] = useState<Record<string, string>>({});
const issueRows = useMemo(
() => buildReviewIssueRows(seoReport, languageReport),
[seoReport, languageReport],
);
const summary = useMemo(() => buildFinalReviewSummary(finalIssues), [finalIssues]);
async function refreshAll() {
const [seo, language, unresolved] = await Promise.all([
fetchSeoReport(articleId).catch(() => null),
fetchLanguageReport(articleId).catch(() => null),
fetchFinalReviewIssues(articleId).catch(() => null),
]);
setSeoReport(seo?.report ?? null);
setLanguageReport(language?.report ?? null);
setFinalIssues(unresolved);
}
async function onRunSeo() {
setBusyKey("seo-run");
setMessage("");
try {
const response = await runSeoReview(articleId);
setSeoReport(response.report);
await refreshAll();
setMessage(`SEO review completed with score ${response.report.score}.`);
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "SEO review failed.");
} finally {
setBusyKey(null);
}
}
async function onRunLanguage() {
setBusyKey("language-run");
setMessage("");
try {
const response = await runLanguageReview(articleId);
setLanguageReport(response.report);
await refreshAll();
setMessage(`Language review completed with score ${response.report.score}.`);
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Language review failed.");
} finally {
setBusyKey(null);
}
}
async function onAccept(kind: "SEO" | "LANGUAGE", suggestionId: string) {
setBusyKey(`accept-${suggestionId}`);
setMessage("");
try {
const response =
kind === "SEO"
? await acceptSeoSuggestion(articleId, suggestionId)
: await acceptLanguageSuggestion(articleId, suggestionId);
await refreshAll();
setMessage(
response.draft
? `Suggestion accepted. Draft v${response.draft.version} created.`
: "Suggestion accepted.",
);
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Accept failed.");
} finally {
setBusyKey(null);
}
}
async function onReject(kind: "SEO" | "LANGUAGE", suggestionId: string) {
setBusyKey(`reject-${suggestionId}`);
setMessage("");
try {
if (kind === "SEO") {
await rejectSeoSuggestion(articleId, suggestionId);
} else {
await rejectLanguageSuggestion(articleId, suggestionId);
}
await refreshAll();
setMessage("Suggestion rejected.");
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Reject failed.");
} finally {
setBusyKey(null);
}
}
async function onEdit(kind: "SEO" | "LANGUAGE", suggestionId: string) {
const appliedText = (edits[suggestionId] ?? "").trim();
if (!appliedText) {
setMessage("Provide edited text before applying.");
return;
}
setBusyKey(`edit-${suggestionId}`);
setMessage("");
try {
const response =
kind === "SEO"
? await editSeoSuggestion(articleId, suggestionId, { applied_text: appliedText })
: await editLanguageSuggestion(articleId, suggestionId, { applied_text: appliedText });
await refreshAll();
setMessage(
response.draft
? `Suggestion edited. Draft v${response.draft.version} created.`
: "Suggestion edited.",
);
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Edit failed.");
} finally {
setBusyKey(null);
}
}
return (
<section className="reviewPanel">
<div className="sectionHeader">
<div>
<h2>SEO and language review</h2>
<p>Unresolved issues: {summary.unresolvedCount}</p>
</div>
<div className="formActions">
<button type="button" onClick={onRunSeo} disabled={busyKey !== null}>
{busyKey === "seo-run" ? "Running..." : "Run SEO review"}
</button>
<button type="button" onClick={onRunLanguage} disabled={busyKey !== null}>
{busyKey === "language-run" ? "Running..." : "Run language review"}
</button>
<button type="button" onClick={() => void refreshAll()} disabled={busyKey !== null}>
Refresh
</button>
</div>
</div>
{message ? <p className={message.includes("failed") ? "formError" : "formSuccess"}>{message}</p> : null}
{summary.unresolvedTitles.length > 0 ? (
<ul className="timeline">
{summary.unresolvedTitles.map((title) => (
<li key={title}>{title}</li>
))}
</ul>
) : null}
<table className="table">
<thead>
<tr>
<th>Kind</th>
<th>Severity</th>
<th>Location</th>
<th>Message</th>
<th>Suggestion</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{issueRows.map((row) => (
<tr key={`${row.reviewKind}-${row.suggestionId}`}>
<td>{row.reviewKind}</td>
<td>{row.severity}</td>
<td>{row.location}</td>
<td>{row.message}</td>
<td>{row.suggestedRewrite || row.suggestedFix || "—"}</td>
<td>{row.status}</td>
<td>
<div className="reviewActions">
<button
type="button"
onClick={() => void onAccept(row.reviewKind, row.suggestionId)}
disabled={busyKey !== null}
>
Accept
</button>
<button
type="button"
onClick={() => void onReject(row.reviewKind, row.suggestionId)}
disabled={busyKey !== null}
>
Reject
</button>
<input
value={edits[row.suggestionId] ?? row.suggestedRewrite}
onChange={(event) =>
setEdits((current) => ({
...current,
[row.suggestionId]: event.target.value,
}))
}
/>
<button
type="button"
onClick={() => void onEdit(row.reviewKind, row.suggestionId)}
disabled={busyKey !== null}
>
Edit
</button>
</div>
</td>
</tr>
))}
{issueRows.length === 0 ? (
<tr>
<td colSpan={7}>No review issues yet.</td>
</tr>
) : null}
</tbody>
</table>
</section>
);
}
@@ -6,6 +6,7 @@ import {
} from "@/features/article-detail/model";
import { ArticleDetailShell } from "@/features/article-detail/ui";
import { DraftEditorPanel } from "@/features/article-draft/ui";
import { ArticleReviewPanel } from "@/features/article-review/ui";
import { MediaLibraryPanel } from "@/features/article-assets/ui";
import { fetchArticleDetail, fetchDrafts } from "@/shared/pipeline-api";
import { ApiError } from "@/shared/pipeline-api";
@@ -42,6 +43,7 @@ export default async function ArticleDetailPage({
initialDrafts={draftsResponse.drafts ?? []}
assets={detail.assets ?? []}
/>
<ArticleReviewPanel articleId={articleId} />
</section>
</main>
);
+99
View File
@@ -23,13 +23,20 @@ import type {
EvidenceResponse,
EvidenceCreateRequest,
EvidenceUpdateRequest,
FinalReviewIssuesResponse,
LanguageReviewReportResponse,
LanguageReviewRunResponse,
PlanListResponse,
PlanResponse,
PlanRevisionRequest,
PlanUpdateRequest,
ResearchListResponse,
ResearchStartResponse,
ReviewSuggestionActionResponse,
ReviewSuggestionEditRequest,
ReviewActionResponse,
SeoReviewReportResponse,
SeoReviewRunResponse,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse,
ScriptConfigVersionResponse,
@@ -523,3 +530,95 @@ export function uploadAssetFile(
);
});
}
export function runSeoReview(articleId: string): Promise<SeoReviewRunResponse> {
return apiPost<unknown, SeoReviewRunResponse>(
`/api/articles/${articleId}/seo/review`,
undefined,
);
}
export function fetchSeoReport(articleId: string): Promise<SeoReviewReportResponse> {
return apiGet<SeoReviewReportResponse>(`/api/articles/${articleId}/seo/report`);
}
export function runLanguageReview(articleId: string): Promise<LanguageReviewRunResponse> {
return apiPost<unknown, LanguageReviewRunResponse>(
`/api/articles/${articleId}/language/review`,
undefined,
);
}
export function fetchLanguageReport(
articleId: string,
): Promise<LanguageReviewReportResponse> {
return apiGet<LanguageReviewReportResponse>(`/api/articles/${articleId}/language/report`);
}
export function acceptSeoSuggestion(
articleId: string,
suggestionId: string,
): Promise<ReviewSuggestionActionResponse> {
return apiPost<unknown, ReviewSuggestionActionResponse>(
`/api/articles/${articleId}/seo/suggestions/${suggestionId}/accept`,
undefined,
);
}
export function rejectSeoSuggestion(
articleId: string,
suggestionId: string,
): Promise<ReviewSuggestionActionResponse> {
return apiPost<unknown, ReviewSuggestionActionResponse>(
`/api/articles/${articleId}/seo/suggestions/${suggestionId}/reject`,
undefined,
);
}
export function editSeoSuggestion(
articleId: string,
suggestionId: string,
request: ReviewSuggestionEditRequest,
): Promise<ReviewSuggestionActionResponse> {
return apiPost<ReviewSuggestionEditRequest, ReviewSuggestionActionResponse>(
`/api/articles/${articleId}/seo/suggestions/${suggestionId}/edit`,
request,
);
}
export function acceptLanguageSuggestion(
articleId: string,
suggestionId: string,
): Promise<ReviewSuggestionActionResponse> {
return apiPost<unknown, ReviewSuggestionActionResponse>(
`/api/articles/${articleId}/language/suggestions/${suggestionId}/accept`,
undefined,
);
}
export function rejectLanguageSuggestion(
articleId: string,
suggestionId: string,
): Promise<ReviewSuggestionActionResponse> {
return apiPost<unknown, ReviewSuggestionActionResponse>(
`/api/articles/${articleId}/language/suggestions/${suggestionId}/reject`,
undefined,
);
}
export function editLanguageSuggestion(
articleId: string,
suggestionId: string,
request: ReviewSuggestionEditRequest,
): Promise<ReviewSuggestionActionResponse> {
return apiPost<ReviewSuggestionEditRequest, ReviewSuggestionActionResponse>(
`/api/articles/${articleId}/language/suggestions/${suggestionId}/edit`,
request,
);
}
export function fetchFinalReviewIssues(
articleId: string,
): Promise<FinalReviewIssuesResponse> {
return apiGet<FinalReviewIssuesResponse>(`/api/articles/${articleId}/final-review/issues`);
}
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const testDir = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(testDir, "../src/features/article-review/model.ts");
const source = readFileSync(sourcePath, "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
});
const moduleExports = {};
new Function("exports", compiled.outputText)(moduleExports);
const { buildReviewIssueRows, buildFinalReviewSummary } = moduleExports;
const seoReport = {
id: "11111111-1111-1111-1111-111111111111",
article_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
review_kind: "SEO",
draft_id: "dddddddd-dddd-dddd-dddd-dddddddddddd",
score: 72,
issues: [
{
id: "meta-description-length",
suggestion_id: "22222222-2222-2222-2222-222222222222",
severity: "HIGH",
location: "meta_description",
message: "Meta description too short.",
suggested_fix: "Use 120-160 chars.",
suggested_rewrite: "Expanded meta description text.",
status: "PENDING",
},
],
recommended_slug: "seo-language-review-guide",
recommended_title: "SEO Language Review Guide",
schema_json: { "@type": "Article" },
rules_snapshot: { meta_description_min: 120 },
created_at: "2026-05-22T12:00:00Z",
};
const languageReport = {
...seoReport,
id: "33333333-3333-3333-3333-333333333333",
review_kind: "LANGUAGE",
issues: [
{
id: "long-sentence",
suggestion_id: "44444444-4444-4444-4444-444444444444",
severity: "MEDIUM",
location: "body_markdown",
message: "Sentence exceeds 24 words.",
suggested_fix: "Split sentence.",
suggested_rewrite: "This sentence is shorter.",
status: "ACCEPTED",
},
],
};
const rows = buildReviewIssueRows(seoReport, languageReport);
assert.equal(rows.length, 2);
assert.equal(rows[0].reviewKind, "SEO");
assert.equal(rows[0].suggestionId, "22222222-2222-2222-2222-222222222222");
assert.equal(rows[1].reviewKind, "LANGUAGE");
assert.equal(rows[1].status, "ACCEPTED");
const summary = buildFinalReviewSummary({
article_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
unresolved_count: 2,
unresolved_issues: [
{
review_kind: "SEO",
suggestion_id: "22222222-2222-2222-2222-222222222222",
severity: "HIGH",
location: "meta_description",
message: "Meta description too short.",
status: "PENDING",
},
{
review_kind: "LANGUAGE",
suggestion_id: "55555555-5555-5555-5555-555555555555",
severity: "MEDIUM",
location: "body_markdown",
message: "Sentence exceeds 24 words.",
status: "PENDING",
},
],
});
assert.equal(summary.unresolvedCount, 2);
assert.equal(summary.unresolvedTitles.length, 2);
assert.ok(summary.unresolvedTitles[0].startsWith("SEO:"));
File diff suppressed because it is too large Load Diff
+87
View File
@@ -204,6 +204,51 @@ export type ClaimSummary = {
export type ClaimSupportStatus = "SUPPORTED" | "UNSUPPORTED" | "NEEDS_REVIEW";
export type ContentReviewIssueSummary = {
id: string;
location: string;
message: string;
severity: string;
status: ReviewSuggestionStatus;
suggested_fix?: string | null;
suggested_rewrite?: string | null;
suggestion_id: string;
};
export type ContentReviewKind = "SEO" | "LANGUAGE";
export type ContentReviewReportSummary = {
article_id: string;
created_at: string;
draft_id: string;
id: string;
issues?: ContentReviewIssueSummary[];
recommended_slug: string;
recommended_title: string;
review_kind: ContentReviewKind;
rules_snapshot?: Record<string, unknown>;
schema_json?: Record<string, unknown>;
score: number;
};
export type ContentReviewSuggestionSummary = {
applied_text?: string | null;
article_id: string;
created_at: string;
id: string;
location: string;
message: string;
patch?: Record<string, unknown>;
report_id: string;
review_kind: ContentReviewKind;
severity: string;
status: ReviewSuggestionStatus;
suggested_fix?: string | null;
suggested_rewrite?: string | null;
suggestion_key: string;
updated_at: string;
};
export type CurrentUser = {
display_name: string;
email: string;
@@ -304,10 +349,33 @@ export type EvidenceUpdateRequest = {
review_status?: string | null;
};
export type FinalReviewIssueSummary = {
location: string;
message: string;
review_kind: ContentReviewKind;
severity: string;
status: ReviewSuggestionStatus;
suggestion_id: string;
};
export type FinalReviewIssuesResponse = {
article_id: string;
unresolved_count: number;
unresolved_issues?: FinalReviewIssueSummary[];
};
export type HTTPValidationError = {
detail?: ValidationError[];
};
export type LanguageReviewReportResponse = {
report: ContentReviewReportSummary;
};
export type LanguageReviewRunResponse = {
report: ContentReviewReportSummary;
};
export type PlanListResponse = {
plans: PlanSummary[];
};
@@ -444,6 +512,17 @@ export type ReviewActionResponse = {
export type ReviewStatus = "PENDING" | "APPROVED" | "CHANGES_REQUESTED";
export type ReviewSuggestionActionResponse = {
draft?: DraftSummary | null;
suggestion: ContentReviewSuggestionSummary;
};
export type ReviewSuggestionEditRequest = {
applied_text: string;
};
export type ReviewSuggestionStatus = "PENDING" | "ACCEPTED" | "REJECTED" | "EDITED";
export type ReviewSummary = {
article_id: string;
created_at: string;
@@ -496,6 +575,14 @@ export type ScriptConfigVersionSummary = {
version: number;
};
export type SeoReviewReportResponse = {
report: ContentReviewReportSummary;
};
export type SeoReviewRunResponse = {
report: ContentReviewReportSummary;
};
export type TargetSiteConfig = {
active_script_config_version_id?: string | null;
audience: string;
+45 -14
View File
@@ -46,14 +46,14 @@ Development description: Implement SEO and linguistic review jobs, issue reports
## Acceptance Criteria
- [ ] TDD pre-requirement: before implementation, write one failing behavior test that runs an SEO review and returns a visible issue through the public API; proceed one review behavior at a time and record evidence in `Result`.
- [ ] SEO report includes score, issues, suggested fixes, recommended slug/title, and schema JSON.
- [ ] Language report includes severity, location, message, and suggested rewrite.
- [ ] Editor can accept, reject, or edit each suggestion.
- [ ] Accepted content changes create a new immutable draft version.
- [ ] Target-site SEO and tone rules override global defaults.
- [ ] Final review can show unresolved SEO/language issues.
- [ ] Fake review runner produces deterministic demo reports.
- [x] TDD pre-requirement: before implementation, write one failing behavior test that runs an SEO review and returns a visible issue through the public API; proceed one review behavior at a time and record evidence in `Result`.
- [x] SEO report includes score, issues, suggested fixes, recommended slug/title, and schema JSON.
- [x] Language report includes severity, location, message, and suggested rewrite.
- [x] Editor can accept, reject, or edit each suggestion.
- [x] Accepted content changes create a new immutable draft version.
- [x] Target-site SEO and tone rules override global defaults.
- [x] Final review can show unresolved SEO/language issues.
- [x] Fake review runner produces deterministic demo reports.
## Verification
@@ -64,9 +64,40 @@ Development description: Implement SEO and linguistic review jobs, issue reports
## Result
- Status: Pending execution.
- TDD plan: To be filled during execution.
- Red evidence: To be filled during execution.
- Green evidence: To be filled during execution.
- Refactor notes: To be filled during execution.
- Verification output: To be filled during execution.
- Status: Completed (pre-requirement RED + implementation GREEN).
- TDD plan (done):
1. Prepare article through public flow to assembled draft state.
2. Run SEO and language review via public endpoints.
3. Fetch reports and verify issue visibility through API.
4. Apply suggestion actions (accept/reject/edit) and verify immutable draft versioning.
5. Verify unresolved issues in final review context and deterministic fake-runner behavior.
- Red evidence:
- Pre-requirement test initially failed with `404 Not Found` on `POST /api/articles/{article_id}/seo/review`.
- Green evidence:
- Added review domain contracts, persistence tables, repository, application service, and routes:
- `POST /api/articles/{article_id}/seo/review`
- `GET /api/articles/{article_id}/seo/report`
- `POST /api/articles/{article_id}/language/review`
- `GET /api/articles/{article_id}/language/report`
- suggestion actions:
- `POST /api/articles/{article_id}/seo/suggestions/{suggestion_id}/accept|reject|edit`
- `POST /api/articles/{article_id}/language/suggestions/{suggestion_id}/accept|reject|edit`
- unresolved issues endpoint:
- `GET /api/articles/{article_id}/final-review/issues`
- Reports are generated deterministically from assembled draft + site rules and stored in review tables.
- Accept/edit actions that modify content/metadata create a new immutable draft version through existing draft versioning flow.
- Site-level overrides are applied from `target_sites.seo_rules` (`seo_review` / `language_review`) over defaults.
- Added frontend review feature model + UI and API bindings.
- Regenerated shared contracts:
- `packages/shared/openapi.json`
- `packages/shared/src/api-types.ts`
- Refactor notes:
- `ContentReviewReportSummary.schema_json` currently emits a pydantic shadow warning; behavior is correct and contracts remain stable.
- Verification output:
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_seo_language_review_public_api.py` -> `OK (6 tests)`
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_draft_assembly_public_api.py` -> `OK (5 tests)`
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_assets_media_library_public_api.py` -> `OK (7 tests)`
- `node apps/frontend/tests/draft_editor.model.test.mjs` -> `OK`
- `node apps/frontend/tests/article_assets.model.test.mjs` -> `OK`
- `node apps/frontend/tests/article_review.model.test.mjs` -> `OK`
- `pnpm --dir apps/frontend typecheck` -> `OK`