From 6dcbe0e36cfe5d762996f9a23523d656b8bf3d5a Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 22 May 2026 00:16:08 +0300 Subject: [PATCH] task015 implement seo and language review flow --- apps/backend/src/application/reviews.py | 645 +++++++++ apps/backend/src/domain/contracts/__init__.py | 26 + apps/backend/src/domain/contracts/enums.py | 12 + apps/backend/src/domain/contracts/models.py | 85 ++ apps/backend/src/domain/contracts/openapi.py | 26 + apps/backend/src/domain/schema.py | 4 + .../src/infrastructure/repositories.py | 458 +++++++ apps/backend/src/infrastructure/schema.py | 86 ++ apps/backend/src/presentation/main.py | 2 + .../src/presentation/routes/reviews.py | 244 ++++ .../test_seo_language_review_public_api.py | 431 ++++++ apps/frontend/src/app/globals.css | 14 + .../src/features/article-review/model.ts | 64 + .../src/features/article-review/ui.tsx | 245 ++++ .../src/pages/article-detail/index.tsx | 2 + apps/frontend/src/shared/pipeline-api.ts | 99 ++ .../tests/article_review.model.test.mjs | 94 ++ packages/shared/openapi.json | 1159 +++++++++++++++++ packages/shared/src/api-types.ts | 87 ++ tasks/015-seo-and-language-review.md | 59 +- 20 files changed, 3828 insertions(+), 14 deletions(-) create mode 100644 apps/backend/src/application/reviews.py create mode 100644 apps/backend/src/presentation/routes/reviews.py create mode 100644 apps/backend/tests/integration/test_seo_language_review_public_api.py create mode 100644 apps/frontend/src/features/article-review/model.ts create mode 100644 apps/frontend/src/features/article-review/ui.tsx create mode 100644 apps/frontend/tests/article_review.model.test.mjs diff --git a/apps/backend/src/application/reviews.py b/apps/backend/src/application/reviews.py new file mode 100644 index 0000000..71ef67d --- /dev/null +++ b/apps/backend/src/application/reviews.py @@ -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) diff --git a/apps/backend/src/domain/contracts/__init__.py b/apps/backend/src/domain/contracts/__init__.py index b7773b4..bc793ae 100644 --- a/apps/backend/src/domain/contracts/__init__.py +++ b/apps/backend/src/domain/contracts/__init__.py @@ -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", diff --git a/apps/backend/src/domain/contracts/enums.py b/apps/backend/src/domain/contracts/enums.py index 5df962d..5cb2737 100644 --- a/apps/backend/src/domain/contracts/enums.py +++ b/apps/backend/src/domain/contracts/enums.py @@ -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" diff --git a/apps/backend/src/domain/contracts/models.py b/apps/backend/src/domain/contracts/models.py index bedb453..a04abb9 100644 --- a/apps/backend/src/domain/contracts/models.py +++ b/apps/backend/src/domain/contracts/models.py @@ -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) diff --git a/apps/backend/src/domain/contracts/openapi.py b/apps/backend/src/domain/contracts/openapi.py index c188422..9d79a91 100644 --- a/apps/backend/src/domain/contracts/openapi.py +++ b/apps/backend/src/domain/contracts/openapi.py @@ -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, diff --git a/apps/backend/src/domain/schema.py b/apps/backend/src/domain/schema.py index 832b8d1..46ec1c7 100644 --- a/apps/backend/src/domain/schema.py +++ b/apps/backend/src/domain/schema.py @@ -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, diff --git a/apps/backend/src/infrastructure/repositories.py b/apps/backend/src/infrastructure/repositories.py index fc97a5d..5f738b2 100644 --- a/apps/backend/src/infrastructure/repositories.py +++ b/apps/backend/src/infrastructure/repositories.py @@ -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"), diff --git a/apps/backend/src/infrastructure/schema.py b/apps/backend/src/infrastructure/schema.py index 7b5c0cb..af12a8f 100644 --- a/apps/backend/src/infrastructure/schema.py +++ b/apps/backend/src/infrastructure/schema.py @@ -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) + """, ) diff --git a/apps/backend/src/presentation/main.py b/apps/backend/src/presentation/main.py index 906a23c..35e0d59 100644 --- a/apps/backend/src/presentation/main.py +++ b/apps/backend/src/presentation/main.py @@ -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) diff --git a/apps/backend/src/presentation/routes/reviews.py b/apps/backend/src/presentation/routes/reviews.py new file mode 100644 index 0000000..db595e9 --- /dev/null +++ b/apps/backend/src/presentation/routes/reviews.py @@ -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 diff --git a/apps/backend/tests/integration/test_seo_language_review_public_api.py b/apps/backend/tests/integration/test_seo_language_review_public_api.py new file mode 100644 index 0000000..4491f1d --- /dev/null +++ b/apps/backend/tests/integration/test_seo_language_review_public_api.py @@ -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() diff --git a/apps/frontend/src/app/globals.css b/apps/frontend/src/app/globals.css index 5d14cbc..3719022 100644 --- a/apps/frontend/src/app/globals.css +++ b/apps/frontend/src/app/globals.css @@ -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; diff --git a/apps/frontend/src/features/article-review/model.ts b/apps/frontend/src/features/article-review/model.ts new file mode 100644 index 0000000..8651358 --- /dev/null +++ b/apps/frontend/src/features/article-review/model.ts @@ -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, + })); +} diff --git a/apps/frontend/src/features/article-review/ui.tsx b/apps/frontend/src/features/article-review/ui.tsx new file mode 100644 index 0000000..f750558 --- /dev/null +++ b/apps/frontend/src/features/article-review/ui.tsx @@ -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(null); + const [languageReport, setLanguageReport] = useState(null); + const [finalIssues, setFinalIssues] = useState(null); + const [busyKey, setBusyKey] = useState(null); + const [message, setMessage] = useState(""); + const [edits, setEdits] = useState>({}); + + 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 ( +
+
+
+

SEO and language review

+

Unresolved issues: {summary.unresolvedCount}

+
+
+ + + +
+
+ {message ?

{message}

: null} + + {summary.unresolvedTitles.length > 0 ? ( +
    + {summary.unresolvedTitles.map((title) => ( +
  • {title}
  • + ))} +
+ ) : null} + + + + + + + + + + + + + + + {issueRows.map((row) => ( + + + + + + + + + + ))} + {issueRows.length === 0 ? ( + + + + ) : null} + +
KindSeverityLocationMessageSuggestionStatusAction
{row.reviewKind}{row.severity}{row.location}{row.message}{row.suggestedRewrite || row.suggestedFix || "—"}{row.status} +
+ + + + setEdits((current) => ({ + ...current, + [row.suggestionId]: event.target.value, + })) + } + /> + +
+
No review issues yet.
+
+ ); +} diff --git a/apps/frontend/src/pages/article-detail/index.tsx b/apps/frontend/src/pages/article-detail/index.tsx index 09006b9..ca60bab 100644 --- a/apps/frontend/src/pages/article-detail/index.tsx +++ b/apps/frontend/src/pages/article-detail/index.tsx @@ -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 ?? []} /> + ); diff --git a/apps/frontend/src/shared/pipeline-api.ts b/apps/frontend/src/shared/pipeline-api.ts index f85abf1..241104d 100644 --- a/apps/frontend/src/shared/pipeline-api.ts +++ b/apps/frontend/src/shared/pipeline-api.ts @@ -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 { + return apiPost( + `/api/articles/${articleId}/seo/review`, + undefined, + ); +} + +export function fetchSeoReport(articleId: string): Promise { + return apiGet(`/api/articles/${articleId}/seo/report`); +} + +export function runLanguageReview(articleId: string): Promise { + return apiPost( + `/api/articles/${articleId}/language/review`, + undefined, + ); +} + +export function fetchLanguageReport( + articleId: string, +): Promise { + return apiGet(`/api/articles/${articleId}/language/report`); +} + +export function acceptSeoSuggestion( + articleId: string, + suggestionId: string, +): Promise { + return apiPost( + `/api/articles/${articleId}/seo/suggestions/${suggestionId}/accept`, + undefined, + ); +} + +export function rejectSeoSuggestion( + articleId: string, + suggestionId: string, +): Promise { + return apiPost( + `/api/articles/${articleId}/seo/suggestions/${suggestionId}/reject`, + undefined, + ); +} + +export function editSeoSuggestion( + articleId: string, + suggestionId: string, + request: ReviewSuggestionEditRequest, +): Promise { + return apiPost( + `/api/articles/${articleId}/seo/suggestions/${suggestionId}/edit`, + request, + ); +} + +export function acceptLanguageSuggestion( + articleId: string, + suggestionId: string, +): Promise { + return apiPost( + `/api/articles/${articleId}/language/suggestions/${suggestionId}/accept`, + undefined, + ); +} + +export function rejectLanguageSuggestion( + articleId: string, + suggestionId: string, +): Promise { + return apiPost( + `/api/articles/${articleId}/language/suggestions/${suggestionId}/reject`, + undefined, + ); +} + +export function editLanguageSuggestion( + articleId: string, + suggestionId: string, + request: ReviewSuggestionEditRequest, +): Promise { + return apiPost( + `/api/articles/${articleId}/language/suggestions/${suggestionId}/edit`, + request, + ); +} + +export function fetchFinalReviewIssues( + articleId: string, +): Promise { + return apiGet(`/api/articles/${articleId}/final-review/issues`); +} diff --git a/apps/frontend/tests/article_review.model.test.mjs b/apps/frontend/tests/article_review.model.test.mjs new file mode 100644 index 0000000..3ebc9fe --- /dev/null +++ b/apps/frontend/tests/article_review.model.test.mjs @@ -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:")); diff --git a/packages/shared/openapi.json b/packages/shared/openapi.json index 97d159a..1ffb0d4 100644 --- a/packages/shared/openapi.json +++ b/packages/shared/openapi.json @@ -1237,6 +1237,266 @@ "title": "ClaimSupportStatus", "type": "string" }, + "ContentReviewIssueSummary": { + "additionalProperties": false, + "properties": { + "id": { + "minLength": 1, + "title": "Id", + "type": "string" + }, + "location": { + "minLength": 1, + "title": "Location", + "type": "string" + }, + "message": { + "minLength": 1, + "title": "Message", + "type": "string" + }, + "severity": { + "minLength": 1, + "title": "Severity", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ReviewSuggestionStatus" + }, + "suggested_fix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Suggested Fix" + }, + "suggested_rewrite": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Suggested Rewrite" + }, + "suggestion_id": { + "format": "uuid", + "title": "Suggestion Id", + "type": "string" + } + }, + "required": [ + "id", + "suggestion_id", + "severity", + "location", + "message", + "status" + ], + "title": "ContentReviewIssueSummary", + "type": "object" + }, + "ContentReviewKind": { + "enum": [ + "SEO", + "LANGUAGE" + ], + "title": "ContentReviewKind", + "type": "string" + }, + "ContentReviewReportSummary": { + "additionalProperties": false, + "properties": { + "article_id": { + "format": "uuid", + "title": "Article Id", + "type": "string" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "draft_id": { + "format": "uuid", + "title": "Draft Id", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "issues": { + "items": { + "$ref": "#/components/schemas/ContentReviewIssueSummary" + }, + "title": "Issues", + "type": "array" + }, + "recommended_slug": { + "minLength": 1, + "title": "Recommended Slug", + "type": "string" + }, + "recommended_title": { + "minLength": 1, + "title": "Recommended Title", + "type": "string" + }, + "review_kind": { + "$ref": "#/components/schemas/ContentReviewKind" + }, + "rules_snapshot": { + "additionalProperties": true, + "title": "Rules Snapshot", + "type": "object" + }, + "schema_json": { + "additionalProperties": true, + "title": "Schema Json", + "type": "object" + }, + "score": { + "maximum": 100, + "minimum": 0, + "title": "Score", + "type": "integer" + } + }, + "required": [ + "id", + "article_id", + "review_kind", + "draft_id", + "score", + "recommended_slug", + "recommended_title", + "created_at" + ], + "title": "ContentReviewReportSummary", + "type": "object" + }, + "ContentReviewSuggestionSummary": { + "additionalProperties": false, + "properties": { + "applied_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Applied Text" + }, + "article_id": { + "format": "uuid", + "title": "Article Id", + "type": "string" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "location": { + "minLength": 1, + "title": "Location", + "type": "string" + }, + "message": { + "minLength": 1, + "title": "Message", + "type": "string" + }, + "patch": { + "additionalProperties": true, + "title": "Patch", + "type": "object" + }, + "report_id": { + "format": "uuid", + "title": "Report Id", + "type": "string" + }, + "review_kind": { + "$ref": "#/components/schemas/ContentReviewKind" + }, + "severity": { + "minLength": 1, + "title": "Severity", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ReviewSuggestionStatus" + }, + "suggested_fix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Suggested Fix" + }, + "suggested_rewrite": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Suggested Rewrite" + }, + "suggestion_key": { + "minLength": 1, + "title": "Suggestion Key", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "id", + "article_id", + "review_kind", + "report_id", + "suggestion_key", + "severity", + "location", + "message", + "status", + "created_at", + "updated_at" + ], + "title": "ContentReviewSuggestionSummary", + "type": "object" + }, "CurrentUser": { "additionalProperties": false, "properties": { @@ -1815,6 +2075,75 @@ "title": "EvidenceUpdateRequest", "type": "object" }, + "FinalReviewIssueSummary": { + "additionalProperties": false, + "properties": { + "location": { + "minLength": 1, + "title": "Location", + "type": "string" + }, + "message": { + "minLength": 1, + "title": "Message", + "type": "string" + }, + "review_kind": { + "$ref": "#/components/schemas/ContentReviewKind" + }, + "severity": { + "minLength": 1, + "title": "Severity", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ReviewSuggestionStatus" + }, + "suggestion_id": { + "format": "uuid", + "title": "Suggestion Id", + "type": "string" + } + }, + "required": [ + "review_kind", + "suggestion_id", + "severity", + "location", + "message", + "status" + ], + "title": "FinalReviewIssueSummary", + "type": "object" + }, + "FinalReviewIssuesResponse": { + "additionalProperties": false, + "properties": { + "article_id": { + "format": "uuid", + "title": "Article Id", + "type": "string" + }, + "unresolved_count": { + "minimum": 0, + "title": "Unresolved Count", + "type": "integer" + }, + "unresolved_issues": { + "items": { + "$ref": "#/components/schemas/FinalReviewIssueSummary" + }, + "title": "Unresolved Issues", + "type": "array" + } + }, + "required": [ + "article_id", + "unresolved_count" + ], + "title": "FinalReviewIssuesResponse", + "type": "object" + }, "HTTPValidationError": { "properties": { "detail": { @@ -1828,6 +2157,32 @@ "title": "HTTPValidationError", "type": "object" }, + "LanguageReviewReportResponse": { + "additionalProperties": false, + "properties": { + "report": { + "$ref": "#/components/schemas/ContentReviewReportSummary" + } + }, + "required": [ + "report" + ], + "title": "LanguageReviewReportResponse", + "type": "object" + }, + "LanguageReviewRunResponse": { + "additionalProperties": false, + "properties": { + "report": { + "$ref": "#/components/schemas/ContentReviewReportSummary" + } + }, + "required": [ + "report" + ], + "title": "LanguageReviewRunResponse", + "type": "object" + }, "PlanListResponse": { "additionalProperties": false, "properties": { @@ -2716,6 +3071,55 @@ "title": "ReviewStatus", "type": "string" }, + "ReviewSuggestionActionResponse": { + "additionalProperties": false, + "properties": { + "draft": { + "anyOf": [ + { + "$ref": "#/components/schemas/DraftSummary" + }, + { + "type": "null" + } + ], + "default": null + }, + "suggestion": { + "$ref": "#/components/schemas/ContentReviewSuggestionSummary" + } + }, + "required": [ + "suggestion" + ], + "title": "ReviewSuggestionActionResponse", + "type": "object" + }, + "ReviewSuggestionEditRequest": { + "additionalProperties": false, + "properties": { + "applied_text": { + "minLength": 1, + "title": "Applied Text", + "type": "string" + } + }, + "required": [ + "applied_text" + ], + "title": "ReviewSuggestionEditRequest", + "type": "object" + }, + "ReviewSuggestionStatus": { + "enum": [ + "PENDING", + "ACCEPTED", + "REJECTED", + "EDITED" + ], + "title": "ReviewSuggestionStatus", + "type": "string" + }, "ReviewSummary": { "additionalProperties": false, "properties": { @@ -3004,6 +3408,32 @@ "title": "ScriptConfigVersionSummary", "type": "object" }, + "SeoReviewReportResponse": { + "additionalProperties": false, + "properties": { + "report": { + "$ref": "#/components/schemas/ContentReviewReportSummary" + } + }, + "required": [ + "report" + ], + "title": "SeoReviewReportResponse", + "type": "object" + }, + "SeoReviewRunResponse": { + "additionalProperties": false, + "properties": { + "report": { + "$ref": "#/components/schemas/ContentReviewReportSummary" + } + }, + "required": [ + "report" + ], + "title": "SeoReviewRunResponse", + "type": "object" + }, "TargetSiteConfig": { "additionalProperties": false, "properties": { @@ -5122,6 +5552,65 @@ ] } }, + "/api/articles/{article_id}/final-review/issues": { + "get": { + "operationId": "get_article_final_review_issues_api_articles__article_id__final_review_issues_get", + "parameters": [ + { + "in": "path", + "name": "article_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Article Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Demo-User-Email", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Demo-User-Email" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FinalReviewIssuesResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Article Final Review Issues", + "tags": [ + "reviews" + ] + } + }, "/api/articles/{article_id}/final/approve": { "post": { "operationId": "post_final_approve_api_articles__article_id__final_approve_post", @@ -5181,6 +5670,341 @@ ] } }, + "/api/articles/{article_id}/language/report": { + "get": { + "operationId": "get_article_language_report_api_articles__article_id__language_report_get", + "parameters": [ + { + "in": "path", + "name": "article_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Article Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Demo-User-Email", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Demo-User-Email" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LanguageReviewReportResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Article Language Report", + "tags": [ + "reviews" + ] + } + }, + "/api/articles/{article_id}/language/review": { + "post": { + "operationId": "post_run_language_review_api_articles__article_id__language_review_post", + "parameters": [ + { + "in": "path", + "name": "article_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Article Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Demo-User-Email", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Demo-User-Email" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LanguageReviewRunResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Post Run Language Review", + "tags": [ + "reviews" + ] + } + }, + "/api/articles/{article_id}/language/suggestions/{suggestion_id}/accept": { + "post": { + "operationId": "post_accept_language_suggestion_api_articles__article_id__language_suggestions__suggestion_id__accept_post", + "parameters": [ + { + "in": "path", + "name": "article_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Article Id", + "type": "string" + } + }, + { + "in": "path", + "name": "suggestion_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Suggestion Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Demo-User-Email", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Demo-User-Email" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewSuggestionActionResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Post Accept Language Suggestion", + "tags": [ + "reviews" + ] + } + }, + "/api/articles/{article_id}/language/suggestions/{suggestion_id}/edit": { + "post": { + "operationId": "post_edit_language_suggestion_api_articles__article_id__language_suggestions__suggestion_id__edit_post", + "parameters": [ + { + "in": "path", + "name": "article_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Article Id", + "type": "string" + } + }, + { + "in": "path", + "name": "suggestion_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Suggestion Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Demo-User-Email", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Demo-User-Email" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewSuggestionEditRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewSuggestionActionResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Post Edit Language Suggestion", + "tags": [ + "reviews" + ] + } + }, + "/api/articles/{article_id}/language/suggestions/{suggestion_id}/reject": { + "post": { + "operationId": "post_reject_language_suggestion_api_articles__article_id__language_suggestions__suggestion_id__reject_post", + "parameters": [ + { + "in": "path", + "name": "article_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Article Id", + "type": "string" + } + }, + { + "in": "path", + "name": "suggestion_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Suggestion Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Demo-User-Email", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Demo-User-Email" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewSuggestionActionResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Post Reject Language Suggestion", + "tags": [ + "reviews" + ] + } + }, "/api/articles/{article_id}/plan/generate": { "post": { "operationId": "post_generate_plan_api_articles__article_id__plan_generate_post", @@ -5770,6 +6594,341 @@ ] } }, + "/api/articles/{article_id}/seo/report": { + "get": { + "operationId": "get_article_seo_report_api_articles__article_id__seo_report_get", + "parameters": [ + { + "in": "path", + "name": "article_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Article Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Demo-User-Email", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Demo-User-Email" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeoReviewReportResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Article Seo Report", + "tags": [ + "reviews" + ] + } + }, + "/api/articles/{article_id}/seo/review": { + "post": { + "operationId": "post_run_seo_review_api_articles__article_id__seo_review_post", + "parameters": [ + { + "in": "path", + "name": "article_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Article Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Demo-User-Email", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Demo-User-Email" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeoReviewRunResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Post Run Seo Review", + "tags": [ + "reviews" + ] + } + }, + "/api/articles/{article_id}/seo/suggestions/{suggestion_id}/accept": { + "post": { + "operationId": "post_accept_seo_suggestion_api_articles__article_id__seo_suggestions__suggestion_id__accept_post", + "parameters": [ + { + "in": "path", + "name": "article_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Article Id", + "type": "string" + } + }, + { + "in": "path", + "name": "suggestion_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Suggestion Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Demo-User-Email", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Demo-User-Email" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewSuggestionActionResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Post Accept Seo Suggestion", + "tags": [ + "reviews" + ] + } + }, + "/api/articles/{article_id}/seo/suggestions/{suggestion_id}/edit": { + "post": { + "operationId": "post_edit_seo_suggestion_api_articles__article_id__seo_suggestions__suggestion_id__edit_post", + "parameters": [ + { + "in": "path", + "name": "article_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Article Id", + "type": "string" + } + }, + { + "in": "path", + "name": "suggestion_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Suggestion Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Demo-User-Email", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Demo-User-Email" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewSuggestionEditRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewSuggestionActionResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Post Edit Seo Suggestion", + "tags": [ + "reviews" + ] + } + }, + "/api/articles/{article_id}/seo/suggestions/{suggestion_id}/reject": { + "post": { + "operationId": "post_reject_seo_suggestion_api_articles__article_id__seo_suggestions__suggestion_id__reject_post", + "parameters": [ + { + "in": "path", + "name": "article_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Article Id", + "type": "string" + } + }, + { + "in": "path", + "name": "suggestion_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Suggestion Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Demo-User-Email", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Demo-User-Email" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewSuggestionActionResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Post Reject Seo Suggestion", + "tags": [ + "reviews" + ] + } + }, "/api/me": { "get": { "operationId": "get_me_api_me_get", diff --git a/packages/shared/src/api-types.ts b/packages/shared/src/api-types.ts index 63f4eea..5bf9da4 100644 --- a/packages/shared/src/api-types.ts +++ b/packages/shared/src/api-types.ts @@ -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; + schema_json?: Record; + score: number; +}; + +export type ContentReviewSuggestionSummary = { + applied_text?: string | null; + article_id: string; + created_at: string; + id: string; + location: string; + message: string; + patch?: Record; + 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; diff --git a/tasks/015-seo-and-language-review.md b/tasks/015-seo-and-language-review.md index 6f94e76..2b7c342 100644 --- a/tasks/015-seo-and-language-review.md +++ b/tasks/015-seo-and-language-review.md @@ -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`