task015 implement seo and language review flow
This commit is contained in:
@@ -0,0 +1,645 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from uuid import NAMESPACE_URL, UUID, uuid5
|
||||
|
||||
from src.application.drafts import update_draft
|
||||
from src.domain.contracts import (
|
||||
ArticleWorkflowStatus,
|
||||
ContentReviewKind,
|
||||
ContentReviewReportSummary,
|
||||
ContentReviewSuggestionSummary,
|
||||
DraftSummary,
|
||||
DraftUpdateRequest,
|
||||
FinalReviewIssueSummary,
|
||||
FinalReviewIssuesResponse,
|
||||
LanguageReviewReportResponse,
|
||||
LanguageReviewRunResponse,
|
||||
ReviewSuggestionActionResponse,
|
||||
ReviewSuggestionEditRequest,
|
||||
ReviewSuggestionStatus,
|
||||
SeoReviewReportResponse,
|
||||
SeoReviewRunResponse,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_SEO_RULES = {
|
||||
"title_min": 30,
|
||||
"title_max": 60,
|
||||
"meta_title_min": 30,
|
||||
"meta_title_max": 60,
|
||||
"meta_description_min": 120,
|
||||
"meta_description_max": 160,
|
||||
"schema_type": "Article",
|
||||
}
|
||||
|
||||
DEFAULT_LANGUAGE_RULES = {
|
||||
"max_sentence_words": 24,
|
||||
"forbidden_phrases": ["best ever", "guaranteed"],
|
||||
"brand_tone": "neutral",
|
||||
}
|
||||
|
||||
|
||||
def run_seo_review(repository: object, *, article_id: UUID) -> SeoReviewRunResponse:
|
||||
article = repository.articles.get(article_id)
|
||||
draft = _require_latest_draft(repository, article_id=article_id)
|
||||
site = repository.target_sites.get_by_id(article.target_site_id)
|
||||
rules = _resolve_seo_rules(site.seo_rules)
|
||||
issues = _build_seo_issues(
|
||||
article_id=article_id,
|
||||
title=draft.title,
|
||||
meta_title=draft.meta_title or draft.title,
|
||||
meta_description=draft.meta_description or "",
|
||||
body_markdown=draft.body_markdown,
|
||||
primary_keyword=article.primary_keyword or "",
|
||||
rules=rules,
|
||||
)
|
||||
recommended_title = _recommended_title(
|
||||
title=draft.title,
|
||||
primary_keyword=article.primary_keyword or "",
|
||||
)
|
||||
recommended_slug = _slugify(recommended_title)
|
||||
score = max(0, 100 - len(issues) * 12)
|
||||
now = _now()
|
||||
report = repository.content_reviews.create_report(
|
||||
article_id=article_id,
|
||||
review_kind=ContentReviewKind.SEO,
|
||||
draft_id=draft.id,
|
||||
score=score,
|
||||
recommended_slug=recommended_slug,
|
||||
recommended_title=recommended_title,
|
||||
schema_json={
|
||||
"@context": "https://schema.org",
|
||||
"@type": str(rules.get("schema_type", "Article")),
|
||||
"headline": recommended_title,
|
||||
"inLanguage": article.language,
|
||||
},
|
||||
rules_snapshot=rules,
|
||||
created_at=now,
|
||||
)
|
||||
_upsert_report_suggestions(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
report_id=report.id,
|
||||
review_kind=ContentReviewKind.SEO,
|
||||
issues=issues,
|
||||
timestamp=now,
|
||||
)
|
||||
latest = repository.content_reviews.get_report(
|
||||
article_id=article_id,
|
||||
report_id=report.id,
|
||||
)
|
||||
_mark_review_ready(repository, article_id=article_id, event_type="SEO_REVIEW_COMPLETED", count=len(latest.issues))
|
||||
return SeoReviewRunResponse(report=latest)
|
||||
|
||||
|
||||
def get_seo_report(repository: object, *, article_id: UUID) -> SeoReviewReportResponse:
|
||||
repository.articles.get(article_id)
|
||||
report = repository.content_reviews.latest_report(
|
||||
article_id=article_id,
|
||||
review_kind=ContentReviewKind.SEO,
|
||||
)
|
||||
if report is None:
|
||||
raise LookupError(f"SEO report not found for article: {article_id}")
|
||||
return SeoReviewReportResponse(report=report)
|
||||
|
||||
|
||||
def run_language_review(repository: object, *, article_id: UUID) -> LanguageReviewRunResponse:
|
||||
article = repository.articles.get(article_id)
|
||||
draft = _require_latest_draft(repository, article_id=article_id)
|
||||
site = repository.target_sites.get_by_id(article.target_site_id)
|
||||
rules = _resolve_language_rules(site.seo_rules, brand_voice=site.brand_voice)
|
||||
issues = _build_language_issues(
|
||||
article_id=article_id,
|
||||
body_markdown=draft.body_markdown,
|
||||
rules=rules,
|
||||
)
|
||||
score = max(0, 100 - len(issues) * 14)
|
||||
now = _now()
|
||||
report = repository.content_reviews.create_report(
|
||||
article_id=article_id,
|
||||
review_kind=ContentReviewKind.LANGUAGE,
|
||||
draft_id=draft.id,
|
||||
score=score,
|
||||
recommended_slug=draft.slug,
|
||||
recommended_title=draft.title,
|
||||
schema_json={
|
||||
"tone": rules.get("brand_tone"),
|
||||
"language": article.language,
|
||||
"max_sentence_words": rules.get("max_sentence_words"),
|
||||
},
|
||||
rules_snapshot=rules,
|
||||
created_at=now,
|
||||
)
|
||||
_upsert_report_suggestions(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
report_id=report.id,
|
||||
review_kind=ContentReviewKind.LANGUAGE,
|
||||
issues=issues,
|
||||
timestamp=now,
|
||||
)
|
||||
latest = repository.content_reviews.get_report(
|
||||
article_id=article_id,
|
||||
report_id=report.id,
|
||||
)
|
||||
_mark_review_ready(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
event_type="LANGUAGE_REVIEW_COMPLETED",
|
||||
count=len(latest.issues),
|
||||
)
|
||||
return LanguageReviewRunResponse(report=latest)
|
||||
|
||||
|
||||
def get_language_report(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
) -> LanguageReviewReportResponse:
|
||||
repository.articles.get(article_id)
|
||||
report = repository.content_reviews.latest_report(
|
||||
article_id=article_id,
|
||||
review_kind=ContentReviewKind.LANGUAGE,
|
||||
)
|
||||
if report is None:
|
||||
raise LookupError(f"Language report not found for article: {article_id}")
|
||||
return LanguageReviewReportResponse(report=report)
|
||||
|
||||
|
||||
def accept_suggestion(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
review_kind: ContentReviewKind,
|
||||
suggestion_id: UUID,
|
||||
) -> ReviewSuggestionActionResponse:
|
||||
suggestion = repository.content_reviews.get_suggestion(
|
||||
article_id=article_id,
|
||||
review_kind=review_kind,
|
||||
suggestion_id=suggestion_id,
|
||||
)
|
||||
draft = _apply_suggestion_to_draft(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
suggestion=suggestion,
|
||||
override_text=None,
|
||||
)
|
||||
updated = repository.content_reviews.update_suggestion(
|
||||
article_id=article_id,
|
||||
review_kind=review_kind,
|
||||
suggestion_id=suggestion_id,
|
||||
status=ReviewSuggestionStatus.ACCEPTED,
|
||||
applied_text=None,
|
||||
updated_at=_now(),
|
||||
)
|
||||
return ReviewSuggestionActionResponse(suggestion=updated, draft=draft)
|
||||
|
||||
|
||||
def reject_suggestion(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
review_kind: ContentReviewKind,
|
||||
suggestion_id: UUID,
|
||||
) -> ReviewSuggestionActionResponse:
|
||||
repository.articles.get(article_id)
|
||||
updated = repository.content_reviews.update_suggestion(
|
||||
article_id=article_id,
|
||||
review_kind=review_kind,
|
||||
suggestion_id=suggestion_id,
|
||||
status=ReviewSuggestionStatus.REJECTED,
|
||||
applied_text=None,
|
||||
updated_at=_now(),
|
||||
)
|
||||
return ReviewSuggestionActionResponse(suggestion=updated, draft=None)
|
||||
|
||||
|
||||
def edit_suggestion(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
review_kind: ContentReviewKind,
|
||||
suggestion_id: UUID,
|
||||
request: ReviewSuggestionEditRequest,
|
||||
) -> ReviewSuggestionActionResponse:
|
||||
suggestion = repository.content_reviews.get_suggestion(
|
||||
article_id=article_id,
|
||||
review_kind=review_kind,
|
||||
suggestion_id=suggestion_id,
|
||||
)
|
||||
draft = _apply_suggestion_to_draft(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
suggestion=suggestion,
|
||||
override_text=request.applied_text,
|
||||
)
|
||||
updated = repository.content_reviews.update_suggestion(
|
||||
article_id=article_id,
|
||||
review_kind=review_kind,
|
||||
suggestion_id=suggestion_id,
|
||||
status=ReviewSuggestionStatus.EDITED,
|
||||
applied_text=request.applied_text,
|
||||
updated_at=_now(),
|
||||
)
|
||||
return ReviewSuggestionActionResponse(suggestion=updated, draft=draft)
|
||||
|
||||
|
||||
def get_final_review_issues(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
) -> FinalReviewIssuesResponse:
|
||||
repository.articles.get(article_id)
|
||||
unresolved = repository.content_reviews.list_unresolved_for_article(article_id=article_id)
|
||||
return FinalReviewIssuesResponse(
|
||||
article_id=article_id,
|
||||
unresolved_count=len(unresolved),
|
||||
unresolved_issues=[
|
||||
FinalReviewIssueSummary(
|
||||
review_kind=item.review_kind,
|
||||
suggestion_id=item.id,
|
||||
severity=item.severity,
|
||||
location=item.location,
|
||||
message=item.message,
|
||||
status=item.status,
|
||||
)
|
||||
for item in unresolved
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _require_latest_draft(repository: object, *, article_id: UUID) -> DraftSummary:
|
||||
draft = repository.article_drafts.latest_for_article(article_id)
|
||||
if draft is None:
|
||||
raise PermissionError("Assembled draft is required before review")
|
||||
return draft
|
||||
|
||||
|
||||
def _resolve_seo_rules(seo_rules: dict[str, object]) -> dict[str, object]:
|
||||
overrides = seo_rules.get("seo_review", {}) if isinstance(seo_rules, dict) else {}
|
||||
result = dict(DEFAULT_SEO_RULES)
|
||||
if isinstance(overrides, dict):
|
||||
result.update(overrides)
|
||||
return result
|
||||
|
||||
|
||||
def _resolve_language_rules(
|
||||
seo_rules: dict[str, object],
|
||||
*,
|
||||
brand_voice: str,
|
||||
) -> dict[str, object]:
|
||||
overrides = seo_rules.get("language_review", {}) if isinstance(seo_rules, dict) else {}
|
||||
result = dict(DEFAULT_LANGUAGE_RULES)
|
||||
if isinstance(overrides, dict):
|
||||
result.update(overrides)
|
||||
if not result.get("brand_tone"):
|
||||
result["brand_tone"] = brand_voice
|
||||
return result
|
||||
|
||||
|
||||
def _build_seo_issues(
|
||||
*,
|
||||
article_id: UUID,
|
||||
title: str,
|
||||
meta_title: str,
|
||||
meta_description: str,
|
||||
body_markdown: str,
|
||||
primary_keyword: str,
|
||||
rules: dict[str, object],
|
||||
) -> list[dict[str, object]]:
|
||||
issues: list[dict[str, object]] = []
|
||||
title_min = int(rules.get("meta_title_min", 30))
|
||||
title_max = int(rules.get("meta_title_max", 60))
|
||||
if len(meta_title) < title_min or len(meta_title) > title_max:
|
||||
suggested = _trim(meta_title or title, min_len=title_min, max_len=title_max)
|
||||
issues.append(
|
||||
{
|
||||
"id": "meta-title-length",
|
||||
"severity": "HIGH",
|
||||
"location": "meta_title",
|
||||
"message": f"Meta title length should be between {title_min} and {title_max} characters.",
|
||||
"suggested_fix": f"Use a {title_min}-{title_max} character meta title.",
|
||||
"suggested_rewrite": suggested,
|
||||
"patch": {"field": "meta_title", "value": suggested},
|
||||
}
|
||||
)
|
||||
desc_min = int(rules.get("meta_description_min", 120))
|
||||
desc_max = int(rules.get("meta_description_max", 160))
|
||||
if len(meta_description) < desc_min or len(meta_description) > desc_max:
|
||||
suggested_description = _trim(meta_description or title, min_len=desc_min, max_len=desc_max)
|
||||
issues.append(
|
||||
{
|
||||
"id": "meta-description-length",
|
||||
"severity": "HIGH",
|
||||
"location": "meta_description",
|
||||
"message": f"Meta description length should be between {desc_min} and {desc_max} characters.",
|
||||
"suggested_fix": "Adjust meta description length to fit SEO rules.",
|
||||
"suggested_rewrite": suggested_description,
|
||||
"patch": {"field": "meta_description", "value": suggested_description},
|
||||
}
|
||||
)
|
||||
if primary_keyword:
|
||||
normalized_keyword = primary_keyword.lower()
|
||||
if normalized_keyword not in body_markdown.lower():
|
||||
append_sentence = f" This guide addresses {primary_keyword} with concrete steps."
|
||||
issues.append(
|
||||
{
|
||||
"id": "keyword-placement",
|
||||
"severity": "MEDIUM",
|
||||
"location": "body_markdown",
|
||||
"message": "Primary keyword is missing from draft body.",
|
||||
"suggested_fix": "Add primary keyword to introduction paragraph.",
|
||||
"suggested_rewrite": append_sentence.strip(),
|
||||
"patch": {
|
||||
"field": "body_append",
|
||||
"value": append_sentence,
|
||||
},
|
||||
}
|
||||
)
|
||||
if "](/" not in body_markdown and "](/" not in body_markdown:
|
||||
issues.append(
|
||||
{
|
||||
"id": "internal-link",
|
||||
"severity": "MEDIUM",
|
||||
"location": "body_markdown",
|
||||
"message": "No internal links found in the draft.",
|
||||
"suggested_fix": "Add at least one internal link to a related page.",
|
||||
"suggested_rewrite": "Add [related guide](/guides/related-topic) in the intro section.",
|
||||
"patch": {},
|
||||
}
|
||||
)
|
||||
headings = re.findall(r"^##\s+(.+)$", body_markdown, flags=re.MULTILINE)
|
||||
duplicate = _first_duplicate(headings)
|
||||
if duplicate:
|
||||
issues.append(
|
||||
{
|
||||
"id": "duplicate-h2",
|
||||
"severity": "LOW",
|
||||
"location": "body_markdown",
|
||||
"message": f"Duplicate H2 heading detected: {duplicate}",
|
||||
"suggested_fix": "Rename duplicate headings to keep a clear document structure.",
|
||||
"suggested_rewrite": None,
|
||||
"patch": {},
|
||||
}
|
||||
)
|
||||
issues.sort(key=lambda item: str(item["id"]))
|
||||
return issues
|
||||
|
||||
|
||||
def _build_language_issues(
|
||||
*,
|
||||
article_id: UUID,
|
||||
body_markdown: str,
|
||||
rules: dict[str, object],
|
||||
) -> list[dict[str, object]]:
|
||||
del article_id
|
||||
issues: list[dict[str, object]] = []
|
||||
max_sentence_words = int(rules.get("max_sentence_words", 24))
|
||||
first_long_sentence = _first_sentence_longer_than(body_markdown, max_sentence_words)
|
||||
if first_long_sentence:
|
||||
rewrite = _shorten_sentence(first_long_sentence, max_words=max_sentence_words)
|
||||
issues.append(
|
||||
{
|
||||
"id": "long-sentence",
|
||||
"severity": "MEDIUM",
|
||||
"location": "body_markdown",
|
||||
"message": f"Sentence exceeds {max_sentence_words} words.",
|
||||
"suggested_fix": "Split or shorten long sentence.",
|
||||
"suggested_rewrite": rewrite,
|
||||
"patch": {
|
||||
"field": "body_replace",
|
||||
"target": first_long_sentence,
|
||||
"value": rewrite,
|
||||
},
|
||||
}
|
||||
)
|
||||
forbidden_phrases = rules.get("forbidden_phrases", [])
|
||||
if isinstance(forbidden_phrases, list):
|
||||
lower_body = body_markdown.lower()
|
||||
for phrase in [str(item) for item in forbidden_phrases]:
|
||||
if phrase.lower() in lower_body:
|
||||
issues.append(
|
||||
{
|
||||
"id": f"forbidden-{_slugify(phrase)}",
|
||||
"severity": "HIGH",
|
||||
"location": "body_markdown",
|
||||
"message": f"Forbidden phrase found: '{phrase}'.",
|
||||
"suggested_fix": "Replace phrase with neutral wording.",
|
||||
"suggested_rewrite": "Replace with measured, evidence-based language.",
|
||||
"patch": {},
|
||||
}
|
||||
)
|
||||
break
|
||||
tone = str(rules.get("brand_tone", "neutral"))
|
||||
if tone and tone.lower() in {"strict", "formal"} and "!" in body_markdown:
|
||||
issues.append(
|
||||
{
|
||||
"id": "tone-exclamation",
|
||||
"severity": "LOW",
|
||||
"location": "body_markdown",
|
||||
"message": "Exclamation marks conflict with strict/formal tone.",
|
||||
"suggested_fix": "Use neutral punctuation.",
|
||||
"suggested_rewrite": None,
|
||||
"patch": {},
|
||||
}
|
||||
)
|
||||
issues.sort(key=lambda item: str(item["id"]))
|
||||
return issues
|
||||
|
||||
|
||||
def _upsert_report_suggestions(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
report_id: UUID,
|
||||
review_kind: ContentReviewKind,
|
||||
issues: list[dict[str, object]],
|
||||
timestamp: datetime,
|
||||
) -> None:
|
||||
for issue in issues:
|
||||
suggestion_id = _deterministic_suggestion_id(
|
||||
article_id=article_id,
|
||||
review_kind=review_kind,
|
||||
suggestion_key=str(issue["id"]),
|
||||
)
|
||||
repository.content_reviews.upsert_suggestion(
|
||||
suggestion_id=suggestion_id,
|
||||
article_id=article_id,
|
||||
review_kind=review_kind,
|
||||
report_id=report_id,
|
||||
suggestion_key=str(issue["id"]),
|
||||
severity=str(issue["severity"]),
|
||||
location=str(issue["location"]),
|
||||
message=str(issue["message"]),
|
||||
suggested_fix=_optional_string(issue.get("suggested_fix")),
|
||||
suggested_rewrite=_optional_string(issue.get("suggested_rewrite")),
|
||||
patch=issue.get("patch", {}) if isinstance(issue.get("patch"), dict) else {},
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def _deterministic_suggestion_id(
|
||||
*,
|
||||
article_id: UUID,
|
||||
review_kind: ContentReviewKind,
|
||||
suggestion_key: str,
|
||||
) -> UUID:
|
||||
return uuid5(
|
||||
NAMESPACE_URL,
|
||||
f"{article_id}:{review_kind.value}:{suggestion_key}",
|
||||
)
|
||||
|
||||
|
||||
def _apply_suggestion_to_draft(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
suggestion: ContentReviewSuggestionSummary,
|
||||
override_text: str | None,
|
||||
) -> DraftSummary | None:
|
||||
request = _build_draft_update_request(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
suggestion=suggestion,
|
||||
override_text=override_text,
|
||||
)
|
||||
if request is None:
|
||||
return None
|
||||
base = _require_latest_draft(repository, article_id=article_id)
|
||||
return update_draft(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
draft_id=base.id,
|
||||
request=request,
|
||||
).draft
|
||||
|
||||
|
||||
def _build_draft_update_request(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
suggestion: ContentReviewSuggestionSummary,
|
||||
override_text: str | None,
|
||||
) -> DraftUpdateRequest | None:
|
||||
patch = suggestion.patch
|
||||
if not isinstance(patch, dict) or not patch:
|
||||
return None
|
||||
base = _require_latest_draft(repository, article_id=article_id)
|
||||
field = _optional_string(patch.get("field"))
|
||||
if field == "meta_title":
|
||||
value = override_text or _optional_string(patch.get("value"))
|
||||
if value and value != (base.meta_title or ""):
|
||||
return DraftUpdateRequest(meta_title=value)
|
||||
return None
|
||||
if field == "meta_description":
|
||||
value = override_text or _optional_string(patch.get("value"))
|
||||
if value and value != (base.meta_description or ""):
|
||||
return DraftUpdateRequest(meta_description=value)
|
||||
return None
|
||||
if field == "title":
|
||||
value = override_text or _optional_string(patch.get("value"))
|
||||
if value and value != base.title:
|
||||
return DraftUpdateRequest(title=value)
|
||||
return None
|
||||
if field == "body_append":
|
||||
value = override_text or _optional_string(patch.get("value"))
|
||||
if value:
|
||||
return DraftUpdateRequest(body_markdown=f"{base.body_markdown}{value}")
|
||||
return None
|
||||
if field == "body_replace":
|
||||
target = _optional_string(patch.get("target"))
|
||||
value = override_text or _optional_string(patch.get("value"))
|
||||
if target and value and target in base.body_markdown:
|
||||
return DraftUpdateRequest(body_markdown=base.body_markdown.replace(target, value, 1))
|
||||
return None
|
||||
|
||||
|
||||
def _mark_review_ready(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
event_type: str,
|
||||
count: int,
|
||||
) -> None:
|
||||
article = repository.articles.get(article_id)
|
||||
now = _now()
|
||||
if article.status != ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY:
|
||||
repository.articles.update_status(
|
||||
article_id=article_id,
|
||||
status=ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
|
||||
updated_at=now,
|
||||
)
|
||||
repository.articles.create_workflow_event(
|
||||
article_id=article_id,
|
||||
event_type=event_type,
|
||||
from_status=article.status,
|
||||
to_status=ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
|
||||
actor_user_id=None,
|
||||
payload={"issue_count": count},
|
||||
created_at=now,
|
||||
)
|
||||
|
||||
|
||||
def _first_sentence_longer_than(value: str, max_words: int) -> str | None:
|
||||
plain = re.sub(r"\[[^\]]+\]\([^)]+\)", "link", value)
|
||||
plain = re.sub(r"^#+\s+", "", plain, flags=re.MULTILINE)
|
||||
sentences = re.split(r"[.!?]\s+", plain)
|
||||
for sentence in sentences:
|
||||
words = [item for item in sentence.strip().split() if item]
|
||||
if len(words) > max_words:
|
||||
return sentence.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _shorten_sentence(sentence: str, *, max_words: int) -> str:
|
||||
words = [item for item in sentence.split() if item]
|
||||
if len(words) <= max_words:
|
||||
return sentence.strip()
|
||||
return " ".join(words[: max_words - 2] + ["with", "evidence."])
|
||||
|
||||
|
||||
def _trim(value: str, *, min_len: int, max_len: int) -> str:
|
||||
cleaned = " ".join(value.split())
|
||||
if len(cleaned) > max_len:
|
||||
return cleaned[: max_len - 1].rstrip() + "…"
|
||||
if len(cleaned) < min_len:
|
||||
suffix = " SEO guidance"
|
||||
while len(cleaned) < min_len:
|
||||
cleaned = f"{cleaned}{suffix}"
|
||||
return cleaned[:max_len]
|
||||
return cleaned
|
||||
|
||||
|
||||
def _first_duplicate(values: list[str]) -> str | None:
|
||||
seen: set[str] = set()
|
||||
for value in values:
|
||||
key = value.strip().lower()
|
||||
if key in seen:
|
||||
return value.strip()
|
||||
seen.add(key)
|
||||
return None
|
||||
|
||||
|
||||
def _recommended_title(*, title: str, primary_keyword: str) -> str:
|
||||
if primary_keyword and primary_keyword.lower() not in title.lower():
|
||||
return _trim(f"{title}: {primary_keyword} guide", min_len=30, max_len=60)
|
||||
return _trim(title, min_len=30, max_len=60)
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
normalized = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||
return normalized or "draft"
|
||||
|
||||
|
||||
def _optional_string(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
@@ -3,6 +3,7 @@ from .enums import (
|
||||
AgentJobStatus,
|
||||
AgentJobType,
|
||||
ArticleWorkflowStatus,
|
||||
ContentReviewKind,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
ClaimRiskLevel,
|
||||
@@ -10,6 +11,7 @@ from .enums import (
|
||||
PlanReviewStatus,
|
||||
PublishingStatus,
|
||||
ReviewStatus,
|
||||
ReviewSuggestionStatus,
|
||||
ReviewType,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
@@ -47,6 +49,9 @@ from .models import (
|
||||
DraftResponse,
|
||||
DraftSummary,
|
||||
DraftUpdateRequest,
|
||||
ContentReviewIssueSummary,
|
||||
ContentReviewReportSummary,
|
||||
ContentReviewSuggestionSummary,
|
||||
EvidenceSummary,
|
||||
EvidenceCreateRequest,
|
||||
EvidenceMatrixResponse,
|
||||
@@ -66,7 +71,15 @@ from .models import (
|
||||
ResearchListResponse,
|
||||
ResearchStartResponse,
|
||||
ReviewActionResponse,
|
||||
ReviewSuggestionActionResponse,
|
||||
ReviewSuggestionEditRequest,
|
||||
ReviewSummary,
|
||||
SeoReviewRunResponse,
|
||||
SeoReviewReportResponse,
|
||||
LanguageReviewRunResponse,
|
||||
LanguageReviewReportResponse,
|
||||
FinalReviewIssueSummary,
|
||||
FinalReviewIssuesResponse,
|
||||
RunnerFileRef,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionListResponse,
|
||||
@@ -121,6 +134,10 @@ __all__ = [
|
||||
"ClaimSupportStatus",
|
||||
"CurrentUser",
|
||||
"CurrentUserResponse",
|
||||
"ContentReviewIssueSummary",
|
||||
"ContentReviewKind",
|
||||
"ContentReviewReportSummary",
|
||||
"ContentReviewSuggestionSummary",
|
||||
"DraftAssembleResponse",
|
||||
"DraftFaqItem",
|
||||
"DraftListResponse",
|
||||
@@ -132,6 +149,10 @@ __all__ = [
|
||||
"EvidenceMatrixResponse",
|
||||
"EvidenceResponse",
|
||||
"EvidenceUpdateRequest",
|
||||
"FinalReviewIssueSummary",
|
||||
"FinalReviewIssuesResponse",
|
||||
"LanguageReviewRunResponse",
|
||||
"LanguageReviewReportResponse",
|
||||
"PlanReviewStatus",
|
||||
"PlanListResponse",
|
||||
"PlanResponse",
|
||||
@@ -149,10 +170,15 @@ __all__ = [
|
||||
"ResearchStartResponse",
|
||||
"ReviewActionResponse",
|
||||
"ReviewStatus",
|
||||
"ReviewSuggestionActionResponse",
|
||||
"ReviewSuggestionEditRequest",
|
||||
"ReviewSuggestionStatus",
|
||||
"ReviewSummary",
|
||||
"ReviewType",
|
||||
"Role",
|
||||
"RunnerFileRef",
|
||||
"SeoReviewRunResponse",
|
||||
"SeoReviewReportResponse",
|
||||
"ScriptConfigVersionCreateRequest",
|
||||
"ScriptConfigVersionListResponse",
|
||||
"ScriptConfigVersionResponse",
|
||||
|
||||
@@ -123,6 +123,18 @@ class ReviewType(str, Enum):
|
||||
FINAL = "FINAL"
|
||||
|
||||
|
||||
class ContentReviewKind(str, Enum):
|
||||
SEO = "SEO"
|
||||
LANGUAGE = "LANGUAGE"
|
||||
|
||||
|
||||
class ReviewSuggestionStatus(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
ACCEPTED = "ACCEPTED"
|
||||
REJECTED = "REJECTED"
|
||||
EDITED = "EDITED"
|
||||
|
||||
|
||||
class ScriptConfigVersionStatus(str, Enum):
|
||||
DRAFT = "DRAFT"
|
||||
ACTIVE = "ACTIVE"
|
||||
|
||||
@@ -11,6 +11,7 @@ from .enums import (
|
||||
AgentJobStatus,
|
||||
AgentJobType,
|
||||
ArticleWorkflowStatus,
|
||||
ContentReviewKind,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
ClaimRiskLevel,
|
||||
@@ -18,6 +19,7 @@ from .enums import (
|
||||
PlanReviewStatus,
|
||||
PublishingStatus,
|
||||
ReviewStatus,
|
||||
ReviewSuggestionStatus,
|
||||
ReviewType,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
@@ -513,6 +515,89 @@ class DraftUpdateRequest(ContractModel):
|
||||
unsupported_claim_warnings: list[str] | None = None
|
||||
|
||||
|
||||
class ContentReviewIssueSummary(ContractModel):
|
||||
id: str = Field(min_length=1)
|
||||
suggestion_id: UUID
|
||||
severity: str = Field(min_length=1)
|
||||
location: str = Field(min_length=1)
|
||||
message: str = Field(min_length=1)
|
||||
suggested_fix: str | None = None
|
||||
suggested_rewrite: str | None = None
|
||||
status: ReviewSuggestionStatus
|
||||
|
||||
|
||||
class ContentReviewReportSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
review_kind: ContentReviewKind
|
||||
draft_id: UUID
|
||||
score: int = Field(ge=0, le=100)
|
||||
issues: list[ContentReviewIssueSummary] = Field(default_factory=list)
|
||||
recommended_slug: str = Field(min_length=1)
|
||||
recommended_title: str = Field(min_length=1)
|
||||
schema_json: JsonObject = Field(default_factory=dict)
|
||||
rules_snapshot: JsonObject = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ContentReviewSuggestionSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
review_kind: ContentReviewKind
|
||||
report_id: UUID
|
||||
suggestion_key: str = Field(min_length=1)
|
||||
severity: str = Field(min_length=1)
|
||||
location: str = Field(min_length=1)
|
||||
message: str = Field(min_length=1)
|
||||
suggested_fix: str | None = None
|
||||
suggested_rewrite: str | None = None
|
||||
patch: JsonObject = Field(default_factory=dict)
|
||||
status: ReviewSuggestionStatus
|
||||
applied_text: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class SeoReviewRunResponse(ContractModel):
|
||||
report: ContentReviewReportSummary
|
||||
|
||||
|
||||
class SeoReviewReportResponse(ContractModel):
|
||||
report: ContentReviewReportSummary
|
||||
|
||||
|
||||
class LanguageReviewRunResponse(ContractModel):
|
||||
report: ContentReviewReportSummary
|
||||
|
||||
|
||||
class LanguageReviewReportResponse(ContractModel):
|
||||
report: ContentReviewReportSummary
|
||||
|
||||
|
||||
class ReviewSuggestionActionResponse(ContractModel):
|
||||
suggestion: ContentReviewSuggestionSummary
|
||||
draft: DraftSummary | None = None
|
||||
|
||||
|
||||
class ReviewSuggestionEditRequest(ContractModel):
|
||||
applied_text: str = Field(min_length=1)
|
||||
|
||||
|
||||
class FinalReviewIssueSummary(ContractModel):
|
||||
review_kind: ContentReviewKind
|
||||
suggestion_id: UUID
|
||||
severity: str = Field(min_length=1)
|
||||
location: str = Field(min_length=1)
|
||||
message: str = Field(min_length=1)
|
||||
status: ReviewSuggestionStatus
|
||||
|
||||
|
||||
class FinalReviewIssuesResponse(ContractModel):
|
||||
article_id: UUID
|
||||
unresolved_count: int = Field(ge=0)
|
||||
unresolved_issues: list[FinalReviewIssueSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AssetGenerateSpecsResponse(ContractModel):
|
||||
assets: list[AssetSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from .enums import (
|
||||
AgentJobStatus,
|
||||
AgentJobType,
|
||||
ArticleWorkflowStatus,
|
||||
ContentReviewKind,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
ClaimRiskLevel,
|
||||
@@ -17,6 +18,7 @@ from .enums import (
|
||||
PlanReviewStatus,
|
||||
PublishingStatus,
|
||||
ReviewStatus,
|
||||
ReviewSuggestionStatus,
|
||||
ReviewType,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
@@ -53,6 +55,9 @@ from .models import (
|
||||
DraftResponse,
|
||||
DraftSummary,
|
||||
DraftUpdateRequest,
|
||||
ContentReviewIssueSummary,
|
||||
ContentReviewReportSummary,
|
||||
ContentReviewSuggestionSummary,
|
||||
EvidenceSummary,
|
||||
EvidenceCreateRequest,
|
||||
EvidenceMatrixResponse,
|
||||
@@ -71,8 +76,16 @@ from .models import (
|
||||
ResearchArtifactSummary,
|
||||
ResearchListResponse,
|
||||
ResearchStartResponse,
|
||||
FinalReviewIssueSummary,
|
||||
FinalReviewIssuesResponse,
|
||||
LanguageReviewReportResponse,
|
||||
LanguageReviewRunResponse,
|
||||
ReviewSuggestionActionResponse,
|
||||
ReviewSuggestionEditRequest,
|
||||
ReviewActionResponse,
|
||||
ReviewSummary,
|
||||
SeoReviewReportResponse,
|
||||
SeoReviewRunResponse,
|
||||
RunnerFileRef,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionListResponse,
|
||||
@@ -100,7 +113,9 @@ CONTRACT_ENUMS: tuple[type[Enum], ...] = (
|
||||
AssetType,
|
||||
PlanReviewStatus,
|
||||
ReviewStatus,
|
||||
ReviewSuggestionStatus,
|
||||
ReviewType,
|
||||
ContentReviewKind,
|
||||
ScriptConfigVersionStatus,
|
||||
)
|
||||
|
||||
@@ -149,6 +164,13 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
||||
DraftListResponse,
|
||||
DraftResponse,
|
||||
DraftUpdateRequest,
|
||||
ContentReviewIssueSummary,
|
||||
ContentReviewReportSummary,
|
||||
ContentReviewSuggestionSummary,
|
||||
FinalReviewIssueSummary,
|
||||
FinalReviewIssuesResponse,
|
||||
LanguageReviewReportResponse,
|
||||
LanguageReviewRunResponse,
|
||||
AssetGenerateSpecsResponse,
|
||||
AssetListResponse,
|
||||
AssetResponse,
|
||||
@@ -159,6 +181,10 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
||||
AssetUploadResponse,
|
||||
ReviewSummary,
|
||||
ReviewActionResponse,
|
||||
ReviewSuggestionActionResponse,
|
||||
ReviewSuggestionEditRequest,
|
||||
SeoReviewReportResponse,
|
||||
SeoReviewRunResponse,
|
||||
PublishCommitSummary,
|
||||
RunnerFileRef,
|
||||
AgentJobSummary,
|
||||
|
||||
@@ -11,6 +11,8 @@ PLAN_SECTIONS_TABLE = "plan_sections"
|
||||
EVIDENCE_ITEMS_TABLE = "evidence_items"
|
||||
CLAIMS_TABLE = "claims"
|
||||
ARTICLE_DRAFTS_TABLE = "article_drafts"
|
||||
ARTICLE_REVIEW_REPORTS_TABLE = "article_review_reports"
|
||||
ARTICLE_REVIEW_SUGGESTIONS_TABLE = "article_review_suggestions"
|
||||
ASSETS_TABLE = "assets"
|
||||
ASSET_REVISIONS_TABLE = "asset_revisions"
|
||||
WORKFLOW_EVENTS_TABLE = "workflow_events"
|
||||
@@ -31,6 +33,8 @@ CORE_TABLES: tuple[str, ...] = (
|
||||
EVIDENCE_ITEMS_TABLE,
|
||||
CLAIMS_TABLE,
|
||||
ARTICLE_DRAFTS_TABLE,
|
||||
ARTICLE_REVIEW_REPORTS_TABLE,
|
||||
ARTICLE_REVIEW_SUGGESTIONS_TABLE,
|
||||
ASSETS_TABLE,
|
||||
ASSET_REVISIONS_TABLE,
|
||||
WORKFLOW_EVENTS_TABLE,
|
||||
|
||||
@@ -13,6 +13,10 @@ from src.domain.contracts import (
|
||||
AgentJobStatus,
|
||||
AgentJobSummary,
|
||||
AgentJobType,
|
||||
ContentReviewKind,
|
||||
ContentReviewIssueSummary,
|
||||
ContentReviewReportSummary,
|
||||
ContentReviewSuggestionSummary,
|
||||
AssetRevisionSummary,
|
||||
AssetStatus,
|
||||
AssetSummary,
|
||||
@@ -33,6 +37,7 @@ from src.domain.contracts import (
|
||||
ResearchArtifactManifestSummary,
|
||||
ResearchArtifactSummary,
|
||||
ArticleWorkflowStatus,
|
||||
ReviewSuggestionStatus,
|
||||
WorkflowEventSummary,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
@@ -59,6 +64,7 @@ class BackendRepository:
|
||||
self.boundary_questions = BoundaryQuestionsRepository(self)
|
||||
self.article_plans = ArticlePlansRepository(self)
|
||||
self.article_drafts = ArticleDraftsRepository(self)
|
||||
self.content_reviews = ContentReviewsRepository(self)
|
||||
self.assets = AssetsRepository(self)
|
||||
self.research_manifests = ResearchManifestsRepository(self)
|
||||
self.evidence_items = EvidenceItemsRepository(self)
|
||||
@@ -1197,6 +1203,418 @@ class ArticleDraftsRepository:
|
||||
"""
|
||||
|
||||
|
||||
class ContentReviewsRepository:
|
||||
def __init__(self, repository: BackendRepository) -> None:
|
||||
self._repository = repository
|
||||
|
||||
def create_report(
|
||||
self,
|
||||
*,
|
||||
article_id: UUID,
|
||||
review_kind: ContentReviewKind,
|
||||
draft_id: UUID,
|
||||
score: int,
|
||||
recommended_slug: str,
|
||||
recommended_title: str,
|
||||
schema_json: JsonObject,
|
||||
rules_snapshot: JsonObject,
|
||||
created_at: datetime,
|
||||
) -> ContentReviewReportSummary:
|
||||
report_id = uuid4()
|
||||
placeholder = self._repository.placeholder()
|
||||
json_cast = self._repository.json_cast()
|
||||
with self._repository.connection() as connection:
|
||||
connection.execute(
|
||||
f"""
|
||||
INSERT INTO article_review_reports (
|
||||
id,
|
||||
article_id,
|
||||
review_kind,
|
||||
draft_id,
|
||||
score,
|
||||
issues,
|
||||
recommended_slug,
|
||||
recommended_title,
|
||||
schema_json,
|
||||
rules_snapshot,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder}{json_cast},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder}{json_cast},
|
||||
{placeholder}{json_cast},
|
||||
{placeholder}
|
||||
)
|
||||
""",
|
||||
(
|
||||
str(report_id),
|
||||
str(article_id),
|
||||
review_kind.value,
|
||||
str(draft_id),
|
||||
score,
|
||||
_json_value([]),
|
||||
recommended_slug,
|
||||
recommended_title,
|
||||
_json_value(schema_json),
|
||||
_json_value(rules_snapshot),
|
||||
_datetime_value(created_at),
|
||||
),
|
||||
)
|
||||
return self.get_report(article_id=article_id, report_id=report_id)
|
||||
|
||||
def list_reports_for_article(
|
||||
self,
|
||||
*,
|
||||
article_id: UUID,
|
||||
review_kind: ContentReviewKind,
|
||||
) -> list[ContentReviewReportSummary]:
|
||||
placeholder = self._repository.placeholder()
|
||||
with self._repository.connection() as connection:
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT
|
||||
id,
|
||||
article_id,
|
||||
review_kind,
|
||||
draft_id,
|
||||
score,
|
||||
recommended_slug,
|
||||
recommended_title,
|
||||
schema_json,
|
||||
rules_snapshot,
|
||||
created_at
|
||||
FROM article_review_reports
|
||||
WHERE article_id = {placeholder}
|
||||
AND review_kind = {placeholder}
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(str(article_id), review_kind.value),
|
||||
).fetchall()
|
||||
return [self.get_report(article_id=article_id, report_id=_row_value(row, "id")) for row in rows]
|
||||
|
||||
def latest_report(
|
||||
self,
|
||||
*,
|
||||
article_id: UUID,
|
||||
review_kind: ContentReviewKind,
|
||||
) -> ContentReviewReportSummary | None:
|
||||
placeholder = self._repository.placeholder()
|
||||
with self._repository.connection() as connection:
|
||||
row = connection.execute(
|
||||
f"""
|
||||
SELECT id
|
||||
FROM article_review_reports
|
||||
WHERE article_id = {placeholder}
|
||||
AND review_kind = {placeholder}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(str(article_id), review_kind.value),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return self.get_report(article_id=article_id, report_id=_row_value(row, "id"))
|
||||
|
||||
def get_report(self, *, article_id: UUID, report_id: UUID) -> ContentReviewReportSummary:
|
||||
placeholder = self._repository.placeholder()
|
||||
with self._repository.connection() as connection:
|
||||
row = connection.execute(
|
||||
f"""
|
||||
SELECT
|
||||
id,
|
||||
article_id,
|
||||
review_kind,
|
||||
draft_id,
|
||||
score,
|
||||
recommended_slug,
|
||||
recommended_title,
|
||||
schema_json,
|
||||
rules_snapshot,
|
||||
created_at
|
||||
FROM article_review_reports
|
||||
WHERE article_id = {placeholder}
|
||||
AND id = {placeholder}
|
||||
""",
|
||||
(str(article_id), str(report_id)),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise LookupError(f"Content review report not found: {report_id}")
|
||||
suggestions = self.list_suggestions_for_report(
|
||||
article_id=article_id,
|
||||
report_id=report_id,
|
||||
)
|
||||
issues = [
|
||||
ContentReviewIssueSummary(
|
||||
id=item.suggestion_key,
|
||||
suggestion_id=item.id,
|
||||
severity=item.severity,
|
||||
location=item.location,
|
||||
message=item.message,
|
||||
suggested_fix=item.suggested_fix,
|
||||
suggested_rewrite=item.suggested_rewrite,
|
||||
status=item.status,
|
||||
)
|
||||
for item in suggestions
|
||||
]
|
||||
return _content_review_report_from_row(row, issues=issues)
|
||||
|
||||
def upsert_suggestion(
|
||||
self,
|
||||
*,
|
||||
suggestion_id: UUID,
|
||||
article_id: UUID,
|
||||
review_kind: ContentReviewKind,
|
||||
report_id: UUID,
|
||||
suggestion_key: str,
|
||||
severity: str,
|
||||
location: str,
|
||||
message: str,
|
||||
suggested_fix: str | None,
|
||||
suggested_rewrite: str | None,
|
||||
patch: JsonObject,
|
||||
created_at: datetime,
|
||||
updated_at: datetime,
|
||||
) -> ContentReviewSuggestionSummary:
|
||||
placeholder = self._repository.placeholder()
|
||||
json_cast = self._repository.json_cast()
|
||||
with self._repository.connection() as connection:
|
||||
connection.execute(
|
||||
f"""
|
||||
INSERT INTO article_review_suggestions (
|
||||
id,
|
||||
article_id,
|
||||
review_kind,
|
||||
report_id,
|
||||
suggestion_key,
|
||||
severity,
|
||||
location,
|
||||
message,
|
||||
suggested_fix,
|
||||
suggested_rewrite,
|
||||
patch,
|
||||
status,
|
||||
applied_text,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder}{json_cast},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder}
|
||||
)
|
||||
ON CONFLICT (article_id, review_kind, suggestion_key) DO UPDATE SET
|
||||
report_id = excluded.report_id,
|
||||
severity = excluded.severity,
|
||||
location = excluded.location,
|
||||
message = excluded.message,
|
||||
suggested_fix = excluded.suggested_fix,
|
||||
suggested_rewrite = excluded.suggested_rewrite,
|
||||
patch = excluded.patch,
|
||||
status = CASE
|
||||
WHEN article_review_suggestions.status = 'PENDING' THEN excluded.status
|
||||
ELSE article_review_suggestions.status
|
||||
END,
|
||||
applied_text = CASE
|
||||
WHEN article_review_suggestions.status = 'PENDING' THEN excluded.applied_text
|
||||
ELSE article_review_suggestions.applied_text
|
||||
END,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
str(suggestion_id),
|
||||
str(article_id),
|
||||
review_kind.value,
|
||||
str(report_id),
|
||||
suggestion_key,
|
||||
severity,
|
||||
location,
|
||||
message,
|
||||
suggested_fix,
|
||||
suggested_rewrite,
|
||||
_json_value(patch),
|
||||
ReviewSuggestionStatus.PENDING.value,
|
||||
None,
|
||||
_datetime_value(created_at),
|
||||
_datetime_value(updated_at),
|
||||
),
|
||||
)
|
||||
return self.get_suggestion(
|
||||
article_id=article_id,
|
||||
review_kind=review_kind,
|
||||
suggestion_id=suggestion_id,
|
||||
)
|
||||
|
||||
def get_suggestion(
|
||||
self,
|
||||
*,
|
||||
article_id: UUID,
|
||||
review_kind: ContentReviewKind,
|
||||
suggestion_id: UUID,
|
||||
) -> ContentReviewSuggestionSummary:
|
||||
placeholder = self._repository.placeholder()
|
||||
with self._repository.connection() as connection:
|
||||
row = connection.execute(
|
||||
f"""
|
||||
SELECT
|
||||
id,
|
||||
article_id,
|
||||
review_kind,
|
||||
report_id,
|
||||
suggestion_key,
|
||||
severity,
|
||||
location,
|
||||
message,
|
||||
suggested_fix,
|
||||
suggested_rewrite,
|
||||
patch,
|
||||
status,
|
||||
applied_text,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM article_review_suggestions
|
||||
WHERE article_id = {placeholder}
|
||||
AND review_kind = {placeholder}
|
||||
AND id = {placeholder}
|
||||
""",
|
||||
(
|
||||
str(article_id),
|
||||
review_kind.value,
|
||||
str(suggestion_id),
|
||||
),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise LookupError(f"Content review suggestion not found: {suggestion_id}")
|
||||
return _content_review_suggestion_from_row(row)
|
||||
|
||||
def list_suggestions_for_report(
|
||||
self,
|
||||
*,
|
||||
article_id: UUID,
|
||||
report_id: UUID,
|
||||
) -> list[ContentReviewSuggestionSummary]:
|
||||
placeholder = self._repository.placeholder()
|
||||
with self._repository.connection() as connection:
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT
|
||||
id,
|
||||
article_id,
|
||||
review_kind,
|
||||
report_id,
|
||||
suggestion_key,
|
||||
severity,
|
||||
location,
|
||||
message,
|
||||
suggested_fix,
|
||||
suggested_rewrite,
|
||||
patch,
|
||||
status,
|
||||
applied_text,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM article_review_suggestions
|
||||
WHERE article_id = {placeholder}
|
||||
AND report_id = {placeholder}
|
||||
ORDER BY suggestion_key
|
||||
""",
|
||||
(str(article_id), str(report_id)),
|
||||
).fetchall()
|
||||
return [_content_review_suggestion_from_row(row) for row in rows]
|
||||
|
||||
def list_unresolved_for_article(
|
||||
self,
|
||||
*,
|
||||
article_id: UUID,
|
||||
) -> list[ContentReviewSuggestionSummary]:
|
||||
placeholder = self._repository.placeholder()
|
||||
with self._repository.connection() as connection:
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT
|
||||
id,
|
||||
article_id,
|
||||
review_kind,
|
||||
report_id,
|
||||
suggestion_key,
|
||||
severity,
|
||||
location,
|
||||
message,
|
||||
suggested_fix,
|
||||
suggested_rewrite,
|
||||
patch,
|
||||
status,
|
||||
applied_text,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM article_review_suggestions
|
||||
WHERE article_id = {placeholder}
|
||||
AND status = {placeholder}
|
||||
ORDER BY review_kind, suggestion_key
|
||||
""",
|
||||
(str(article_id), ReviewSuggestionStatus.PENDING.value),
|
||||
).fetchall()
|
||||
return [_content_review_suggestion_from_row(row) for row in rows]
|
||||
|
||||
def update_suggestion(
|
||||
self,
|
||||
*,
|
||||
article_id: UUID,
|
||||
review_kind: ContentReviewKind,
|
||||
suggestion_id: UUID,
|
||||
status: ReviewSuggestionStatus,
|
||||
applied_text: str | None,
|
||||
updated_at: datetime,
|
||||
) -> ContentReviewSuggestionSummary:
|
||||
placeholder = self._repository.placeholder()
|
||||
with self._repository.connection() as connection:
|
||||
cursor = connection.execute(
|
||||
f"""
|
||||
UPDATE article_review_suggestions
|
||||
SET
|
||||
status = {placeholder},
|
||||
applied_text = {placeholder},
|
||||
updated_at = {placeholder}
|
||||
WHERE article_id = {placeholder}
|
||||
AND review_kind = {placeholder}
|
||||
AND id = {placeholder}
|
||||
""",
|
||||
(
|
||||
status.value,
|
||||
applied_text,
|
||||
_datetime_value(updated_at),
|
||||
str(article_id),
|
||||
review_kind.value,
|
||||
str(suggestion_id),
|
||||
),
|
||||
)
|
||||
if int(getattr(cursor, "rowcount", 0)) <= 0:
|
||||
raise LookupError(f"Content review suggestion not found: {suggestion_id}")
|
||||
return self.get_suggestion(
|
||||
article_id=article_id,
|
||||
review_kind=review_kind,
|
||||
suggestion_id=suggestion_id,
|
||||
)
|
||||
|
||||
|
||||
class AssetsRepository:
|
||||
def __init__(self, repository: BackendRepository) -> None:
|
||||
self._repository = repository
|
||||
@@ -2707,6 +3125,46 @@ def _draft_summary_from_row(row: Any) -> DraftSummary:
|
||||
)
|
||||
|
||||
|
||||
def _content_review_report_from_row(
|
||||
row: Any,
|
||||
*,
|
||||
issues: list[ContentReviewIssueSummary],
|
||||
) -> ContentReviewReportSummary:
|
||||
return ContentReviewReportSummary(
|
||||
id=_row_value(row, "id"),
|
||||
article_id=_row_value(row, "article_id"),
|
||||
review_kind=_row_value(row, "review_kind"),
|
||||
draft_id=_row_value(row, "draft_id"),
|
||||
score=_row_value(row, "score"),
|
||||
issues=issues,
|
||||
recommended_slug=_row_value(row, "recommended_slug"),
|
||||
recommended_title=_row_value(row, "recommended_title"),
|
||||
schema_json=_json_from_row(row, "schema_json"),
|
||||
rules_snapshot=_json_from_row(row, "rules_snapshot"),
|
||||
created_at=_row_value(row, "created_at"),
|
||||
)
|
||||
|
||||
|
||||
def _content_review_suggestion_from_row(row: Any) -> ContentReviewSuggestionSummary:
|
||||
return ContentReviewSuggestionSummary(
|
||||
id=_row_value(row, "id"),
|
||||
article_id=_row_value(row, "article_id"),
|
||||
review_kind=_row_value(row, "review_kind"),
|
||||
report_id=_row_value(row, "report_id"),
|
||||
suggestion_key=_row_value(row, "suggestion_key"),
|
||||
severity=_row_value(row, "severity"),
|
||||
location=_row_value(row, "location"),
|
||||
message=_row_value(row, "message"),
|
||||
suggested_fix=_row_value(row, "suggested_fix"),
|
||||
suggested_rewrite=_row_value(row, "suggested_rewrite"),
|
||||
patch=_json_from_row(row, "patch"),
|
||||
status=_row_value(row, "status"),
|
||||
applied_text=_row_value(row, "applied_text"),
|
||||
created_at=_row_value(row, "created_at"),
|
||||
updated_at=_row_value(row, "updated_at"),
|
||||
)
|
||||
|
||||
|
||||
def _asset_revision_from_row(row: Any) -> AssetRevisionSummary:
|
||||
return AssetRevisionSummary(
|
||||
id=_row_value(row, "id"),
|
||||
|
||||
@@ -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)
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from src.presentation.routes.boundary_questions import router as boundary_questi
|
||||
from src.presentation.routes.drafts import router as drafts_router
|
||||
from src.presentation.routes.evidence import router as evidence_router
|
||||
from src.presentation.routes.plans import router as plans_router
|
||||
from src.presentation.routes.reviews import router as reviews_router
|
||||
from src.presentation.routes.sites import router as sites_router
|
||||
|
||||
|
||||
@@ -27,6 +28,7 @@ app.include_router(boundary_questions_router)
|
||||
app.include_router(plans_router)
|
||||
app.include_router(evidence_router)
|
||||
app.include_router(drafts_router)
|
||||
app.include_router(reviews_router)
|
||||
app.include_router(agent_jobs_router)
|
||||
app.include_router(internal_agent_jobs_router)
|
||||
app.include_router(sites_router)
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from src.application.reviews import (
|
||||
accept_suggestion,
|
||||
edit_suggestion,
|
||||
get_final_review_issues,
|
||||
get_language_report,
|
||||
get_seo_report,
|
||||
reject_suggestion,
|
||||
run_language_review,
|
||||
run_seo_review,
|
||||
)
|
||||
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
||||
from src.domain.contracts import (
|
||||
ContentReviewKind,
|
||||
CurrentUser,
|
||||
FinalReviewIssuesResponse,
|
||||
LanguageReviewReportResponse,
|
||||
LanguageReviewRunResponse,
|
||||
ReviewSuggestionActionResponse,
|
||||
ReviewSuggestionEditRequest,
|
||||
SeoReviewReportResponse,
|
||||
SeoReviewRunResponse,
|
||||
)
|
||||
from src.infrastructure.repositories import BackendRepository
|
||||
from src.presentation.dependencies import get_repository, require_roles
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["reviews"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles/{article_id}/seo/review",
|
||||
response_model=SeoReviewRunResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def post_run_seo_review(
|
||||
article_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> SeoReviewRunResponse:
|
||||
try:
|
||||
return run_seo_review(repository, article_id=article_id)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
except PermissionError as error:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||
|
||||
|
||||
@router.get(
|
||||
"/articles/{article_id}/seo/report",
|
||||
response_model=SeoReviewReportResponse,
|
||||
)
|
||||
def get_article_seo_report(
|
||||
article_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> SeoReviewReportResponse:
|
||||
try:
|
||||
return get_seo_report(repository, article_id=article_id)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles/{article_id}/language/review",
|
||||
response_model=LanguageReviewRunResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def post_run_language_review(
|
||||
article_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> LanguageReviewRunResponse:
|
||||
try:
|
||||
return run_language_review(repository, article_id=article_id)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
except PermissionError as error:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||
|
||||
|
||||
@router.get(
|
||||
"/articles/{article_id}/language/report",
|
||||
response_model=LanguageReviewReportResponse,
|
||||
)
|
||||
def get_article_language_report(
|
||||
article_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> LanguageReviewReportResponse:
|
||||
try:
|
||||
return get_language_report(repository, article_id=article_id)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles/{article_id}/seo/suggestions/{suggestion_id}/accept",
|
||||
response_model=ReviewSuggestionActionResponse,
|
||||
)
|
||||
def post_accept_seo_suggestion(
|
||||
article_id: UUID,
|
||||
suggestion_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> ReviewSuggestionActionResponse:
|
||||
try:
|
||||
return accept_suggestion(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
review_kind=ContentReviewKind.SEO,
|
||||
suggestion_id=suggestion_id,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles/{article_id}/seo/suggestions/{suggestion_id}/reject",
|
||||
response_model=ReviewSuggestionActionResponse,
|
||||
)
|
||||
def post_reject_seo_suggestion(
|
||||
article_id: UUID,
|
||||
suggestion_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> ReviewSuggestionActionResponse:
|
||||
try:
|
||||
return reject_suggestion(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
review_kind=ContentReviewKind.SEO,
|
||||
suggestion_id=suggestion_id,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles/{article_id}/seo/suggestions/{suggestion_id}/edit",
|
||||
response_model=ReviewSuggestionActionResponse,
|
||||
)
|
||||
def post_edit_seo_suggestion(
|
||||
article_id: UUID,
|
||||
suggestion_id: UUID,
|
||||
request: ReviewSuggestionEditRequest,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> ReviewSuggestionActionResponse:
|
||||
try:
|
||||
return edit_suggestion(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
review_kind=ContentReviewKind.SEO,
|
||||
suggestion_id=suggestion_id,
|
||||
request=request,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles/{article_id}/language/suggestions/{suggestion_id}/accept",
|
||||
response_model=ReviewSuggestionActionResponse,
|
||||
)
|
||||
def post_accept_language_suggestion(
|
||||
article_id: UUID,
|
||||
suggestion_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> ReviewSuggestionActionResponse:
|
||||
try:
|
||||
return accept_suggestion(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
review_kind=ContentReviewKind.LANGUAGE,
|
||||
suggestion_id=suggestion_id,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles/{article_id}/language/suggestions/{suggestion_id}/reject",
|
||||
response_model=ReviewSuggestionActionResponse,
|
||||
)
|
||||
def post_reject_language_suggestion(
|
||||
article_id: UUID,
|
||||
suggestion_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> ReviewSuggestionActionResponse:
|
||||
try:
|
||||
return reject_suggestion(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
review_kind=ContentReviewKind.LANGUAGE,
|
||||
suggestion_id=suggestion_id,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles/{article_id}/language/suggestions/{suggestion_id}/edit",
|
||||
response_model=ReviewSuggestionActionResponse,
|
||||
)
|
||||
def post_edit_language_suggestion(
|
||||
article_id: UUID,
|
||||
suggestion_id: UUID,
|
||||
request: ReviewSuggestionEditRequest,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> ReviewSuggestionActionResponse:
|
||||
try:
|
||||
return edit_suggestion(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
review_kind=ContentReviewKind.LANGUAGE,
|
||||
suggestion_id=suggestion_id,
|
||||
request=request,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.get(
|
||||
"/articles/{article_id}/final-review/issues",
|
||||
response_model=FinalReviewIssuesResponse,
|
||||
)
|
||||
def get_article_final_review_issues(
|
||||
article_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> FinalReviewIssuesResponse:
|
||||
try:
|
||||
return get_final_review_issues(repository, article_id=article_id)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
Reference in New Issue
Block a user