Compare commits

...
2 Commits
31 changed files with 6251 additions and 36 deletions
+2
View File
@@ -64,6 +64,7 @@ def get_article_detail(
research_manifests = repository.research_manifests.list_for_article(article_id)
evidence = repository.evidence_items.list_for_article(article_id)
claims = repository.claims.list_for_article(article_id)
assets = repository.assets.list_for_article(article_id)
agent_jobs = repository.agent_jobs.list_for_article(article_id)
return ArticleDetailResponse(
article=article,
@@ -74,6 +75,7 @@ def get_article_detail(
draft=draft,
evidence=evidence,
claims=claims,
assets=assets,
agent_jobs=agent_jobs,
research_manifests=research_manifests,
)
+361
View File
@@ -0,0 +1,361 @@
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
from uuid import UUID
from src.domain.contracts import (
AssetGenerateSpecsResponse,
AssetListResponse,
AssetResponse,
AssetStatus,
AssetSummary,
AssetType,
AssetUpdateRequest,
AssetUploadResponse,
CurrentUser,
PlanReviewStatus,
)
from src.infrastructure.object_storage import ObjectStorageClient
def generate_asset_specs(
repository: object,
*,
article_id: UUID,
current_user: CurrentUser,
) -> AssetGenerateSpecsResponse:
repository.articles.get(article_id)
plan = _approved_plan(repository, article_id=article_id)
if plan is None or not plan.sections:
raise PermissionError("Approved plan with sections is required before asset specs")
now = _now()
created_assets: list[AssetSummary] = []
for index, section in enumerate(plan.sections, start=1):
asset_type = _asset_type_for_section(index)
title = f"{section.heading} ({asset_type.value})"
asset = repository.assets.create(
article_id=article_id,
section_id=section.id,
asset_type=asset_type,
title=title,
prompt=(
"Create an editorial-ready visual for section "
f"'{section.heading}' with evidence-backed details."
),
object_key=None,
file_url=None,
alt_text=f"Visual for {section.heading}",
caption=f"Supporting visual for section: {section.heading}",
status=AssetStatus.GENERATED,
created_at=now,
updated_at=now,
)
revision_index = repository.assets.next_revision_index(
article_id=article_id,
asset_id=asset.id,
)
repository.assets.create_revision(
article_id=article_id,
asset_id=asset.id,
revision_index=revision_index,
action="SPEC_GENERATED",
actor_user_id=current_user.id,
payload={
"section_id": str(section.id),
"asset_type": asset.asset_type.value,
"title": asset.title,
},
created_at=now,
)
created_assets.append(
repository.assets.get(article_id=article_id, asset_id=asset.id)
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="ASSET_SPECS_GENERATED",
from_status=None,
to_status=None,
actor_user_id=current_user.id,
payload={
"count": len(created_assets),
"asset_ids": [str(asset.id) for asset in created_assets],
},
created_at=now,
)
return AssetGenerateSpecsResponse(assets=created_assets)
def list_assets(repository: object, *, article_id: UUID) -> AssetListResponse:
repository.articles.get(article_id)
return AssetListResponse(assets=repository.assets.list_for_article(article_id))
def update_asset(
repository: object,
*,
article_id: UUID,
asset_id: UUID,
request: AssetUpdateRequest,
current_user: CurrentUser,
) -> AssetResponse:
current = repository.assets.get(article_id=article_id, asset_id=asset_id)
next_section_id = request.section_id if request.section_id is not None else current.section_id
next_title = request.title if request.title is not None else current.title
next_prompt = request.prompt if request.prompt is not None else current.prompt
next_alt_text = request.alt_text if request.alt_text is not None else current.alt_text
next_caption = request.caption if request.caption is not None else current.caption
next_status = request.status if request.status is not None else current.status
changed_fields = _changed_fields(
current=current,
section_id=next_section_id,
title=next_title,
prompt=next_prompt,
alt_text=next_alt_text,
caption=next_caption,
status=next_status,
)
if not changed_fields:
return AssetResponse(asset=current)
now = _now()
repository.assets.update(
article_id=article_id,
asset_id=asset_id,
section_id=next_section_id,
title=next_title,
prompt=next_prompt,
object_key=current.object_key,
file_url=current.file_url,
alt_text=next_alt_text,
caption=next_caption,
status=next_status,
updated_at=now,
)
revision_index = repository.assets.next_revision_index(
article_id=article_id,
asset_id=asset_id,
)
repository.assets.create_revision(
article_id=article_id,
asset_id=asset_id,
revision_index=revision_index,
action="ASSET_UPDATED",
actor_user_id=current_user.id,
payload={
"changed_fields": changed_fields,
"status": next_status.value,
},
created_at=now,
)
if current.status != next_status:
repository.articles.create_workflow_event(
article_id=article_id,
event_type="ASSET_STATUS_UPDATED",
from_status=None,
to_status=None,
actor_user_id=current_user.id,
payload={
"asset_id": str(asset_id),
"from_status": current.status.value,
"to_status": next_status.value,
},
created_at=now,
)
refreshed = repository.assets.get(article_id=article_id, asset_id=asset_id)
return AssetResponse(asset=refreshed)
def approve_asset(
repository: object,
*,
article_id: UUID,
asset_id: UUID,
current_user: CurrentUser,
) -> AssetResponse:
current = repository.assets.get(article_id=article_id, asset_id=asset_id)
if current.status == AssetStatus.APPROVED:
return AssetResponse(asset=current)
now = _now()
repository.assets.update(
article_id=article_id,
asset_id=asset_id,
section_id=current.section_id,
title=current.title,
prompt=current.prompt,
object_key=current.object_key,
file_url=current.file_url,
alt_text=current.alt_text,
caption=current.caption,
status=AssetStatus.APPROVED,
updated_at=now,
)
approved = repository.assets.get(article_id=article_id, asset_id=asset_id)
revision_index = repository.assets.next_revision_index(
article_id=article_id,
asset_id=asset_id,
)
repository.assets.create_revision(
article_id=article_id,
asset_id=asset_id,
revision_index=revision_index,
action="ASSET_APPROVED",
actor_user_id=current_user.id,
payload={
"from_status": current.status.value,
"to_status": AssetStatus.APPROVED.value,
"object_key": approved.object_key,
},
created_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="ASSET_APPROVED",
from_status=None,
to_status=None,
actor_user_id=current_user.id,
payload={
"asset_id": str(asset_id),
"from_status": current.status.value,
"to_status": AssetStatus.APPROVED.value,
"object_key": approved.object_key,
"file_url": approved.file_url,
},
created_at=now,
)
refreshed = repository.assets.get(article_id=article_id, asset_id=asset_id)
return AssetResponse(asset=refreshed)
def upload_asset_file(
repository: object,
*,
article_id: UUID,
asset_id: UUID,
filename: str,
content_type: str | None,
content: bytes,
current_user: CurrentUser,
object_storage: ObjectStorageClient,
) -> AssetUploadResponse:
current = repository.assets.get(article_id=article_id, asset_id=asset_id)
now = _now()
revision_index = repository.assets.next_revision_index(
article_id=article_id,
asset_id=asset_id,
)
safe_filename = _safe_filename(filename)
object_key = f"articles/{article_id}/assets/{asset_id}/r{revision_index}/{safe_filename}"
file_url = object_storage.put_bytes(
object_key=object_key,
content=content,
content_type=content_type or "application/octet-stream",
)
had_previous_file = bool(current.object_key or current.file_url)
repository.assets.update(
article_id=article_id,
asset_id=asset_id,
section_id=current.section_id,
title=current.title,
prompt=current.prompt,
object_key=object_key,
file_url=file_url,
alt_text=current.alt_text,
caption=current.caption,
status=AssetStatus.PENDING,
updated_at=now,
)
action = "ASSET_FILE_REPLACED" if had_previous_file else "ASSET_FILE_UPLOADED"
repository.assets.create_revision(
article_id=article_id,
asset_id=asset_id,
revision_index=revision_index,
action=action,
actor_user_id=current_user.id,
payload={
"previous_object_key": current.object_key,
"previous_file_url": current.file_url,
"next_object_key": object_key,
"next_file_url": file_url,
"content_type": content_type or "application/octet-stream",
"filename": safe_filename,
},
created_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type=action,
from_status=None,
to_status=None,
actor_user_id=current_user.id,
payload={
"asset_id": str(asset_id),
"previous_object_key": current.object_key,
"next_object_key": object_key,
},
created_at=now,
)
refreshed = repository.assets.get(article_id=article_id, asset_id=asset_id)
return AssetUploadResponse(asset=refreshed)
def _approved_plan(repository: object, *, article_id: UUID) -> object | None:
plans = repository.article_plans.list_for_article(article_id)
approved_plans = [plan for plan in plans if plan.status == PlanReviewStatus.APPROVED]
if not approved_plans:
return None
approved_plans.sort(key=lambda plan: plan.version)
return approved_plans[-1]
def _asset_type_for_section(index: int) -> AssetType:
ordered_types = [
AssetType.HERO_IMAGE,
AssetType.ARCHITECTURE_DIAGRAM,
AssetType.DIAGRAM,
AssetType.TABLE,
AssetType.FLOWCHART,
AssetType.COMPARISON_MATRIX,
AssetType.INLINE_IMAGE,
]
return ordered_types[(index - 1) % len(ordered_types)]
def _changed_fields(
*,
current: AssetSummary,
section_id: UUID | None,
title: str,
prompt: str | None,
alt_text: str | None,
caption: str | None,
status: AssetStatus,
) -> list[str]:
changed: list[str] = []
if current.section_id != section_id:
changed.append("section_id")
if current.title != title:
changed.append("title")
if current.prompt != prompt:
changed.append("prompt")
if current.alt_text != alt_text:
changed.append("alt_text")
if current.caption != caption:
changed.append("caption")
if current.status != status:
changed.append("status")
return changed
def _safe_filename(value: str) -> str:
filename = Path(value).name.strip()
if not filename:
return "asset.bin"
return filename.replace("/", "_")
def _now() -> datetime:
return datetime.now(UTC)
+34
View File
@@ -9,6 +9,7 @@ from src.domain.contracts import (
AgentJobStatus,
AgentJobSummary,
AgentJobType,
AssetStatus,
ArticleWorkflowStatus,
DraftAssembleResponse,
DraftFaqItem,
@@ -51,6 +52,10 @@ def assemble_draft(repository: object, *, article_id: UUID) -> DraftAssembleResp
visual_placeholders: list[str] = []
evidence_references: list[str] = []
unsupported_warnings = _collect_job_warnings(section_jobs)
approved_assets_by_section = _approved_assets_by_section(
repository,
article_id=article_id,
)
for section in approved_plan.sections:
section_job = successful_jobs_by_section[str(section.id)]
@@ -61,6 +66,7 @@ def assemble_draft(repository: object, *, article_id: UUID) -> DraftAssembleResp
section_id=section.id,
heading=section.heading,
payload=payload,
approved_assets=approved_assets_by_section.get(str(section.id), []),
)
)
evidence_references.extend(_string_list(payload.get("used_evidence_ids")))
@@ -279,7 +285,11 @@ def _visual_placeholders_for_section(
section_id: UUID,
heading: str,
payload: dict[str, Any],
approved_assets: list[object],
) -> list[str]:
if approved_assets:
return [_approved_asset_markdown(asset) for asset in approved_assets]
suggested_visuals = payload.get("suggested_visuals")
placeholders: list[str] = []
if isinstance(suggested_visuals, list):
@@ -293,6 +303,30 @@ def _visual_placeholders_for_section(
return [f"![Visual placeholder: {heading}](placeholder://{section_id}/1)"]
def _approved_assets_by_section(repository: object, *, article_id: UUID) -> dict[str, list[object]]:
assets = repository.assets.list_for_article(article_id)
by_section: dict[str, list[object]] = {}
for asset in assets:
if asset.status != AssetStatus.APPROVED:
continue
if not asset.section_id:
continue
if not asset.file_url:
continue
by_section.setdefault(str(asset.section_id), []).append(asset)
return by_section
def _approved_asset_markdown(asset: object) -> str:
alt = _string_value(getattr(asset, "alt_text", None)) or _string_value(
getattr(asset, "title", None)
)
src = _string_value(getattr(asset, "file_url", None))
if not src:
return ""
return f"![{alt}]({src})"
def _visual_label(value: Any, *, heading: str, fallback_index: int) -> str:
if isinstance(value, str) and value.strip():
return value.strip()
+645
View File
@@ -0,0 +1,645 @@
from __future__ import annotations
import re
from datetime import UTC, datetime
from uuid import NAMESPACE_URL, UUID, uuid5
from src.application.drafts import update_draft
from src.domain.contracts import (
ArticleWorkflowStatus,
ContentReviewKind,
ContentReviewReportSummary,
ContentReviewSuggestionSummary,
DraftSummary,
DraftUpdateRequest,
FinalReviewIssueSummary,
FinalReviewIssuesResponse,
LanguageReviewReportResponse,
LanguageReviewRunResponse,
ReviewSuggestionActionResponse,
ReviewSuggestionEditRequest,
ReviewSuggestionStatus,
SeoReviewReportResponse,
SeoReviewRunResponse,
)
DEFAULT_SEO_RULES = {
"title_min": 30,
"title_max": 60,
"meta_title_min": 30,
"meta_title_max": 60,
"meta_description_min": 120,
"meta_description_max": 160,
"schema_type": "Article",
}
DEFAULT_LANGUAGE_RULES = {
"max_sentence_words": 24,
"forbidden_phrases": ["best ever", "guaranteed"],
"brand_tone": "neutral",
}
def run_seo_review(repository: object, *, article_id: UUID) -> SeoReviewRunResponse:
article = repository.articles.get(article_id)
draft = _require_latest_draft(repository, article_id=article_id)
site = repository.target_sites.get_by_id(article.target_site_id)
rules = _resolve_seo_rules(site.seo_rules)
issues = _build_seo_issues(
article_id=article_id,
title=draft.title,
meta_title=draft.meta_title or draft.title,
meta_description=draft.meta_description or "",
body_markdown=draft.body_markdown,
primary_keyword=article.primary_keyword or "",
rules=rules,
)
recommended_title = _recommended_title(
title=draft.title,
primary_keyword=article.primary_keyword or "",
)
recommended_slug = _slugify(recommended_title)
score = max(0, 100 - len(issues) * 12)
now = _now()
report = repository.content_reviews.create_report(
article_id=article_id,
review_kind=ContentReviewKind.SEO,
draft_id=draft.id,
score=score,
recommended_slug=recommended_slug,
recommended_title=recommended_title,
schema_json={
"@context": "https://schema.org",
"@type": str(rules.get("schema_type", "Article")),
"headline": recommended_title,
"inLanguage": article.language,
},
rules_snapshot=rules,
created_at=now,
)
_upsert_report_suggestions(
repository,
article_id=article_id,
report_id=report.id,
review_kind=ContentReviewKind.SEO,
issues=issues,
timestamp=now,
)
latest = repository.content_reviews.get_report(
article_id=article_id,
report_id=report.id,
)
_mark_review_ready(repository, article_id=article_id, event_type="SEO_REVIEW_COMPLETED", count=len(latest.issues))
return SeoReviewRunResponse(report=latest)
def get_seo_report(repository: object, *, article_id: UUID) -> SeoReviewReportResponse:
repository.articles.get(article_id)
report = repository.content_reviews.latest_report(
article_id=article_id,
review_kind=ContentReviewKind.SEO,
)
if report is None:
raise LookupError(f"SEO report not found for article: {article_id}")
return SeoReviewReportResponse(report=report)
def run_language_review(repository: object, *, article_id: UUID) -> LanguageReviewRunResponse:
article = repository.articles.get(article_id)
draft = _require_latest_draft(repository, article_id=article_id)
site = repository.target_sites.get_by_id(article.target_site_id)
rules = _resolve_language_rules(site.seo_rules, brand_voice=site.brand_voice)
issues = _build_language_issues(
article_id=article_id,
body_markdown=draft.body_markdown,
rules=rules,
)
score = max(0, 100 - len(issues) * 14)
now = _now()
report = repository.content_reviews.create_report(
article_id=article_id,
review_kind=ContentReviewKind.LANGUAGE,
draft_id=draft.id,
score=score,
recommended_slug=draft.slug,
recommended_title=draft.title,
schema_json={
"tone": rules.get("brand_tone"),
"language": article.language,
"max_sentence_words": rules.get("max_sentence_words"),
},
rules_snapshot=rules,
created_at=now,
)
_upsert_report_suggestions(
repository,
article_id=article_id,
report_id=report.id,
review_kind=ContentReviewKind.LANGUAGE,
issues=issues,
timestamp=now,
)
latest = repository.content_reviews.get_report(
article_id=article_id,
report_id=report.id,
)
_mark_review_ready(
repository,
article_id=article_id,
event_type="LANGUAGE_REVIEW_COMPLETED",
count=len(latest.issues),
)
return LanguageReviewRunResponse(report=latest)
def get_language_report(
repository: object,
*,
article_id: UUID,
) -> LanguageReviewReportResponse:
repository.articles.get(article_id)
report = repository.content_reviews.latest_report(
article_id=article_id,
review_kind=ContentReviewKind.LANGUAGE,
)
if report is None:
raise LookupError(f"Language report not found for article: {article_id}")
return LanguageReviewReportResponse(report=report)
def accept_suggestion(
repository: object,
*,
article_id: UUID,
review_kind: ContentReviewKind,
suggestion_id: UUID,
) -> ReviewSuggestionActionResponse:
suggestion = repository.content_reviews.get_suggestion(
article_id=article_id,
review_kind=review_kind,
suggestion_id=suggestion_id,
)
draft = _apply_suggestion_to_draft(
repository,
article_id=article_id,
suggestion=suggestion,
override_text=None,
)
updated = repository.content_reviews.update_suggestion(
article_id=article_id,
review_kind=review_kind,
suggestion_id=suggestion_id,
status=ReviewSuggestionStatus.ACCEPTED,
applied_text=None,
updated_at=_now(),
)
return ReviewSuggestionActionResponse(suggestion=updated, draft=draft)
def reject_suggestion(
repository: object,
*,
article_id: UUID,
review_kind: ContentReviewKind,
suggestion_id: UUID,
) -> ReviewSuggestionActionResponse:
repository.articles.get(article_id)
updated = repository.content_reviews.update_suggestion(
article_id=article_id,
review_kind=review_kind,
suggestion_id=suggestion_id,
status=ReviewSuggestionStatus.REJECTED,
applied_text=None,
updated_at=_now(),
)
return ReviewSuggestionActionResponse(suggestion=updated, draft=None)
def edit_suggestion(
repository: object,
*,
article_id: UUID,
review_kind: ContentReviewKind,
suggestion_id: UUID,
request: ReviewSuggestionEditRequest,
) -> ReviewSuggestionActionResponse:
suggestion = repository.content_reviews.get_suggestion(
article_id=article_id,
review_kind=review_kind,
suggestion_id=suggestion_id,
)
draft = _apply_suggestion_to_draft(
repository,
article_id=article_id,
suggestion=suggestion,
override_text=request.applied_text,
)
updated = repository.content_reviews.update_suggestion(
article_id=article_id,
review_kind=review_kind,
suggestion_id=suggestion_id,
status=ReviewSuggestionStatus.EDITED,
applied_text=request.applied_text,
updated_at=_now(),
)
return ReviewSuggestionActionResponse(suggestion=updated, draft=draft)
def get_final_review_issues(
repository: object,
*,
article_id: UUID,
) -> FinalReviewIssuesResponse:
repository.articles.get(article_id)
unresolved = repository.content_reviews.list_unresolved_for_article(article_id=article_id)
return FinalReviewIssuesResponse(
article_id=article_id,
unresolved_count=len(unresolved),
unresolved_issues=[
FinalReviewIssueSummary(
review_kind=item.review_kind,
suggestion_id=item.id,
severity=item.severity,
location=item.location,
message=item.message,
status=item.status,
)
for item in unresolved
],
)
def _require_latest_draft(repository: object, *, article_id: UUID) -> DraftSummary:
draft = repository.article_drafts.latest_for_article(article_id)
if draft is None:
raise PermissionError("Assembled draft is required before review")
return draft
def _resolve_seo_rules(seo_rules: dict[str, object]) -> dict[str, object]:
overrides = seo_rules.get("seo_review", {}) if isinstance(seo_rules, dict) else {}
result = dict(DEFAULT_SEO_RULES)
if isinstance(overrides, dict):
result.update(overrides)
return result
def _resolve_language_rules(
seo_rules: dict[str, object],
*,
brand_voice: str,
) -> dict[str, object]:
overrides = seo_rules.get("language_review", {}) if isinstance(seo_rules, dict) else {}
result = dict(DEFAULT_LANGUAGE_RULES)
if isinstance(overrides, dict):
result.update(overrides)
if not result.get("brand_tone"):
result["brand_tone"] = brand_voice
return result
def _build_seo_issues(
*,
article_id: UUID,
title: str,
meta_title: str,
meta_description: str,
body_markdown: str,
primary_keyword: str,
rules: dict[str, object],
) -> list[dict[str, object]]:
issues: list[dict[str, object]] = []
title_min = int(rules.get("meta_title_min", 30))
title_max = int(rules.get("meta_title_max", 60))
if len(meta_title) < title_min or len(meta_title) > title_max:
suggested = _trim(meta_title or title, min_len=title_min, max_len=title_max)
issues.append(
{
"id": "meta-title-length",
"severity": "HIGH",
"location": "meta_title",
"message": f"Meta title length should be between {title_min} and {title_max} characters.",
"suggested_fix": f"Use a {title_min}-{title_max} character meta title.",
"suggested_rewrite": suggested,
"patch": {"field": "meta_title", "value": suggested},
}
)
desc_min = int(rules.get("meta_description_min", 120))
desc_max = int(rules.get("meta_description_max", 160))
if len(meta_description) < desc_min or len(meta_description) > desc_max:
suggested_description = _trim(meta_description or title, min_len=desc_min, max_len=desc_max)
issues.append(
{
"id": "meta-description-length",
"severity": "HIGH",
"location": "meta_description",
"message": f"Meta description length should be between {desc_min} and {desc_max} characters.",
"suggested_fix": "Adjust meta description length to fit SEO rules.",
"suggested_rewrite": suggested_description,
"patch": {"field": "meta_description", "value": suggested_description},
}
)
if primary_keyword:
normalized_keyword = primary_keyword.lower()
if normalized_keyword not in body_markdown.lower():
append_sentence = f" This guide addresses {primary_keyword} with concrete steps."
issues.append(
{
"id": "keyword-placement",
"severity": "MEDIUM",
"location": "body_markdown",
"message": "Primary keyword is missing from draft body.",
"suggested_fix": "Add primary keyword to introduction paragraph.",
"suggested_rewrite": append_sentence.strip(),
"patch": {
"field": "body_append",
"value": append_sentence,
},
}
)
if "](/" not in body_markdown and "](/" not in body_markdown:
issues.append(
{
"id": "internal-link",
"severity": "MEDIUM",
"location": "body_markdown",
"message": "No internal links found in the draft.",
"suggested_fix": "Add at least one internal link to a related page.",
"suggested_rewrite": "Add [related guide](/guides/related-topic) in the intro section.",
"patch": {},
}
)
headings = re.findall(r"^##\s+(.+)$", body_markdown, flags=re.MULTILINE)
duplicate = _first_duplicate(headings)
if duplicate:
issues.append(
{
"id": "duplicate-h2",
"severity": "LOW",
"location": "body_markdown",
"message": f"Duplicate H2 heading detected: {duplicate}",
"suggested_fix": "Rename duplicate headings to keep a clear document structure.",
"suggested_rewrite": None,
"patch": {},
}
)
issues.sort(key=lambda item: str(item["id"]))
return issues
def _build_language_issues(
*,
article_id: UUID,
body_markdown: str,
rules: dict[str, object],
) -> list[dict[str, object]]:
del article_id
issues: list[dict[str, object]] = []
max_sentence_words = int(rules.get("max_sentence_words", 24))
first_long_sentence = _first_sentence_longer_than(body_markdown, max_sentence_words)
if first_long_sentence:
rewrite = _shorten_sentence(first_long_sentence, max_words=max_sentence_words)
issues.append(
{
"id": "long-sentence",
"severity": "MEDIUM",
"location": "body_markdown",
"message": f"Sentence exceeds {max_sentence_words} words.",
"suggested_fix": "Split or shorten long sentence.",
"suggested_rewrite": rewrite,
"patch": {
"field": "body_replace",
"target": first_long_sentence,
"value": rewrite,
},
}
)
forbidden_phrases = rules.get("forbidden_phrases", [])
if isinstance(forbidden_phrases, list):
lower_body = body_markdown.lower()
for phrase in [str(item) for item in forbidden_phrases]:
if phrase.lower() in lower_body:
issues.append(
{
"id": f"forbidden-{_slugify(phrase)}",
"severity": "HIGH",
"location": "body_markdown",
"message": f"Forbidden phrase found: '{phrase}'.",
"suggested_fix": "Replace phrase with neutral wording.",
"suggested_rewrite": "Replace with measured, evidence-based language.",
"patch": {},
}
)
break
tone = str(rules.get("brand_tone", "neutral"))
if tone and tone.lower() in {"strict", "formal"} and "!" in body_markdown:
issues.append(
{
"id": "tone-exclamation",
"severity": "LOW",
"location": "body_markdown",
"message": "Exclamation marks conflict with strict/formal tone.",
"suggested_fix": "Use neutral punctuation.",
"suggested_rewrite": None,
"patch": {},
}
)
issues.sort(key=lambda item: str(item["id"]))
return issues
def _upsert_report_suggestions(
repository: object,
*,
article_id: UUID,
report_id: UUID,
review_kind: ContentReviewKind,
issues: list[dict[str, object]],
timestamp: datetime,
) -> None:
for issue in issues:
suggestion_id = _deterministic_suggestion_id(
article_id=article_id,
review_kind=review_kind,
suggestion_key=str(issue["id"]),
)
repository.content_reviews.upsert_suggestion(
suggestion_id=suggestion_id,
article_id=article_id,
review_kind=review_kind,
report_id=report_id,
suggestion_key=str(issue["id"]),
severity=str(issue["severity"]),
location=str(issue["location"]),
message=str(issue["message"]),
suggested_fix=_optional_string(issue.get("suggested_fix")),
suggested_rewrite=_optional_string(issue.get("suggested_rewrite")),
patch=issue.get("patch", {}) if isinstance(issue.get("patch"), dict) else {},
created_at=timestamp,
updated_at=timestamp,
)
def _deterministic_suggestion_id(
*,
article_id: UUID,
review_kind: ContentReviewKind,
suggestion_key: str,
) -> UUID:
return uuid5(
NAMESPACE_URL,
f"{article_id}:{review_kind.value}:{suggestion_key}",
)
def _apply_suggestion_to_draft(
repository: object,
*,
article_id: UUID,
suggestion: ContentReviewSuggestionSummary,
override_text: str | None,
) -> DraftSummary | None:
request = _build_draft_update_request(
repository,
article_id=article_id,
suggestion=suggestion,
override_text=override_text,
)
if request is None:
return None
base = _require_latest_draft(repository, article_id=article_id)
return update_draft(
repository,
article_id=article_id,
draft_id=base.id,
request=request,
).draft
def _build_draft_update_request(
repository: object,
*,
article_id: UUID,
suggestion: ContentReviewSuggestionSummary,
override_text: str | None,
) -> DraftUpdateRequest | None:
patch = suggestion.patch
if not isinstance(patch, dict) or not patch:
return None
base = _require_latest_draft(repository, article_id=article_id)
field = _optional_string(patch.get("field"))
if field == "meta_title":
value = override_text or _optional_string(patch.get("value"))
if value and value != (base.meta_title or ""):
return DraftUpdateRequest(meta_title=value)
return None
if field == "meta_description":
value = override_text or _optional_string(patch.get("value"))
if value and value != (base.meta_description or ""):
return DraftUpdateRequest(meta_description=value)
return None
if field == "title":
value = override_text or _optional_string(patch.get("value"))
if value and value != base.title:
return DraftUpdateRequest(title=value)
return None
if field == "body_append":
value = override_text or _optional_string(patch.get("value"))
if value:
return DraftUpdateRequest(body_markdown=f"{base.body_markdown}{value}")
return None
if field == "body_replace":
target = _optional_string(patch.get("target"))
value = override_text or _optional_string(patch.get("value"))
if target and value and target in base.body_markdown:
return DraftUpdateRequest(body_markdown=base.body_markdown.replace(target, value, 1))
return None
def _mark_review_ready(
repository: object,
*,
article_id: UUID,
event_type: str,
count: int,
) -> None:
article = repository.articles.get(article_id)
now = _now()
if article.status != ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY:
repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type=event_type,
from_status=article.status,
to_status=ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
actor_user_id=None,
payload={"issue_count": count},
created_at=now,
)
def _first_sentence_longer_than(value: str, max_words: int) -> str | None:
plain = re.sub(r"\[[^\]]+\]\([^)]+\)", "link", value)
plain = re.sub(r"^#+\s+", "", plain, flags=re.MULTILINE)
sentences = re.split(r"[.!?]\s+", plain)
for sentence in sentences:
words = [item for item in sentence.strip().split() if item]
if len(words) > max_words:
return sentence.strip()
return None
def _shorten_sentence(sentence: str, *, max_words: int) -> str:
words = [item for item in sentence.split() if item]
if len(words) <= max_words:
return sentence.strip()
return " ".join(words[: max_words - 2] + ["with", "evidence."])
def _trim(value: str, *, min_len: int, max_len: int) -> str:
cleaned = " ".join(value.split())
if len(cleaned) > max_len:
return cleaned[: max_len - 1].rstrip() + "…"
if len(cleaned) < min_len:
suffix = " SEO guidance"
while len(cleaned) < min_len:
cleaned = f"{cleaned}{suffix}"
return cleaned[:max_len]
return cleaned
def _first_duplicate(values: list[str]) -> str | None:
seen: set[str] = set()
for value in values:
key = value.strip().lower()
if key in seen:
return value.strip()
seen.add(key)
return None
def _recommended_title(*, title: str, primary_keyword: str) -> str:
if primary_keyword and primary_keyword.lower() not in title.lower():
return _trim(f"{title}: {primary_keyword} guide", min_len=30, max_len=60)
return _trim(title, min_len=30, max_len=60)
def _slugify(value: str) -> str:
normalized = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return normalized or "draft"
def _optional_string(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _now() -> datetime:
return datetime.now(UTC)
@@ -3,6 +3,7 @@ from .enums import (
AgentJobStatus,
AgentJobType,
ArticleWorkflowStatus,
ContentReviewKind,
AssetStatus,
AssetType,
ClaimRiskLevel,
@@ -10,6 +11,7 @@ from .enums import (
PlanReviewStatus,
PublishingStatus,
ReviewStatus,
ReviewSuggestionStatus,
ReviewType,
Role,
ScriptConfigVersionStatus,
@@ -30,7 +32,14 @@ from .models import (
BoundaryQuestionSummary,
BoundaryQuestionUpdateRequest,
WorkflowEventSummary,
AssetGenerateSpecsResponse,
AssetListResponse,
AssetResponse,
AssetRevisionSummary,
AssetSummary,
AssetUpdateRequest,
AssetUploadRequest,
AssetUploadResponse,
ClaimSummary,
CurrentUser,
CurrentUserResponse,
@@ -40,6 +49,9 @@ from .models import (
DraftResponse,
DraftSummary,
DraftUpdateRequest,
ContentReviewIssueSummary,
ContentReviewReportSummary,
ContentReviewSuggestionSummary,
EvidenceSummary,
EvidenceCreateRequest,
EvidenceMatrixResponse,
@@ -59,7 +71,15 @@ from .models import (
ResearchListResponse,
ResearchStartResponse,
ReviewActionResponse,
ReviewSuggestionActionResponse,
ReviewSuggestionEditRequest,
ReviewSummary,
SeoReviewRunResponse,
SeoReviewReportResponse,
LanguageReviewRunResponse,
LanguageReviewReportResponse,
FinalReviewIssueSummary,
FinalReviewIssuesResponse,
RunnerFileRef,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse,
@@ -98,15 +118,26 @@ __all__ = [
"BoundaryQuestionSummary",
"BoundaryQuestionUpdateRequest",
"WorkflowEventSummary",
"AssetGenerateSpecsResponse",
"AssetListResponse",
"AssetResponse",
"AssetRevisionSummary",
"ArticleWorkflowStatus",
"AssetStatus",
"AssetSummary",
"AssetType",
"AssetUpdateRequest",
"AssetUploadRequest",
"AssetUploadResponse",
"ClaimRiskLevel",
"ClaimSummary",
"ClaimSupportStatus",
"CurrentUser",
"CurrentUserResponse",
"ContentReviewIssueSummary",
"ContentReviewKind",
"ContentReviewReportSummary",
"ContentReviewSuggestionSummary",
"DraftAssembleResponse",
"DraftFaqItem",
"DraftListResponse",
@@ -118,6 +149,10 @@ __all__ = [
"EvidenceMatrixResponse",
"EvidenceResponse",
"EvidenceUpdateRequest",
"FinalReviewIssueSummary",
"FinalReviewIssuesResponse",
"LanguageReviewRunResponse",
"LanguageReviewReportResponse",
"PlanReviewStatus",
"PlanListResponse",
"PlanResponse",
@@ -135,10 +170,15 @@ __all__ = [
"ResearchStartResponse",
"ReviewActionResponse",
"ReviewStatus",
"ReviewSuggestionActionResponse",
"ReviewSuggestionEditRequest",
"ReviewSuggestionStatus",
"ReviewSummary",
"ReviewType",
"Role",
"RunnerFileRef",
"SeoReviewRunResponse",
"SeoReviewReportResponse",
"ScriptConfigVersionCreateRequest",
"ScriptConfigVersionListResponse",
"ScriptConfigVersionResponse",
@@ -97,6 +97,9 @@ class AssetType(str, Enum):
HERO_IMAGE = "hero_image"
DIAGRAM = "diagram"
TABLE = "table"
FLOWCHART = "flowchart"
COMPARISON_MATRIX = "comparison_matrix"
ARCHITECTURE_DIAGRAM = "architecture_diagram"
INLINE_IMAGE = "inline_image"
@@ -120,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"
+132
View File
@@ -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,
@@ -386,16 +388,32 @@ class DraftSummary(ContractModel):
updated_at: datetime
class AssetRevisionSummary(ContractModel):
id: UUID
asset_id: UUID
article_id: UUID
revision_index: int = Field(ge=1)
action: str = Field(min_length=1)
actor_user_id: UUID | None = None
payload: JsonObject = Field(default_factory=dict)
created_at: datetime
class AssetSummary(ContractModel):
id: UUID
article_id: UUID
section_id: UUID | None = None
asset_type: AssetType
title: str = Field(min_length=1)
prompt: str | None = None
object_key: str | None = None
file_url: str | None = None
alt_text: str | None = None
caption: str | None = None
status: AssetStatus
history: list[AssetRevisionSummary] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class ReviewSummary(ContractModel):
@@ -497,6 +515,120 @@ 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)
class AssetListResponse(ContractModel):
assets: list[AssetSummary] = Field(default_factory=list)
class AssetResponse(ContractModel):
asset: AssetSummary
class AssetUpdateRequest(ContractModel):
title: str | None = Field(default=None, min_length=1)
prompt: str | None = None
alt_text: str | None = None
caption: str | None = None
section_id: UUID | None = None
status: AssetStatus | None = None
class AssetUploadRequest(ContractModel):
filename: str = Field(min_length=1)
content_base64: str = Field(min_length=1)
content_type: str | None = None
class AssetUploadResponse(ContractModel):
asset: AssetSummary
class ArticleDetailResponse(ContractModel):
article: ArticleSummary
target_site: TargetSiteConfig | None = None
@@ -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,
@@ -36,7 +38,14 @@ from .models import (
BoundaryQuestionResponse,
BoundaryQuestionSummary,
BoundaryQuestionUpdateRequest,
AssetGenerateSpecsResponse,
AssetListResponse,
AssetResponse,
AssetRevisionSummary,
AssetSummary,
AssetUpdateRequest,
AssetUploadRequest,
AssetUploadResponse,
ClaimSummary,
CurrentUser,
CurrentUserResponse,
@@ -46,6 +55,9 @@ from .models import (
DraftResponse,
DraftSummary,
DraftUpdateRequest,
ContentReviewIssueSummary,
ContentReviewReportSummary,
ContentReviewSuggestionSummary,
EvidenceSummary,
EvidenceCreateRequest,
EvidenceMatrixResponse,
@@ -64,8 +76,16 @@ from .models import (
ResearchArtifactSummary,
ResearchListResponse,
ResearchStartResponse,
FinalReviewIssueSummary,
FinalReviewIssuesResponse,
LanguageReviewReportResponse,
LanguageReviewRunResponse,
ReviewSuggestionActionResponse,
ReviewSuggestionEditRequest,
ReviewActionResponse,
ReviewSummary,
SeoReviewReportResponse,
SeoReviewRunResponse,
RunnerFileRef,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse,
@@ -93,7 +113,9 @@ CONTRACT_ENUMS: tuple[type[Enum], ...] = (
AssetType,
PlanReviewStatus,
ReviewStatus,
ReviewSuggestionStatus,
ReviewType,
ContentReviewKind,
ScriptConfigVersionStatus,
)
@@ -142,9 +164,27 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
DraftListResponse,
DraftResponse,
DraftUpdateRequest,
ContentReviewIssueSummary,
ContentReviewReportSummary,
ContentReviewSuggestionSummary,
FinalReviewIssueSummary,
FinalReviewIssuesResponse,
LanguageReviewReportResponse,
LanguageReviewRunResponse,
AssetGenerateSpecsResponse,
AssetListResponse,
AssetResponse,
AssetRevisionSummary,
AssetSummary,
AssetUpdateRequest,
AssetUploadRequest,
AssetUploadResponse,
ReviewSummary,
ReviewActionResponse,
ReviewSuggestionActionResponse,
ReviewSuggestionEditRequest,
SeoReviewReportResponse,
SeoReviewRunResponse,
PublishCommitSummary,
RunnerFileRef,
AgentJobSummary,
+6
View File
@@ -11,7 +11,10 @@ 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"
AGENT_JOBS_TABLE = "agent_jobs"
RESEARCH_RUN_MANIFESTS_TABLE = "research_run_manifests"
@@ -30,7 +33,10 @@ 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,
AGENT_JOBS_TABLE,
RESEARCH_RUN_MANIFESTS_TABLE,
@@ -8,6 +8,15 @@ class ObjectStorageClient:
def put_text(self, *, object_key: str, content: str) -> str:
raise NotImplementedError
def put_bytes(
self,
*,
object_key: str,
content: bytes,
content_type: str = "application/octet-stream",
) -> str:
raise NotImplementedError
class LocalObjectStorageClient(ObjectStorageClient):
def __init__(self, root: Path) -> None:
@@ -19,6 +28,19 @@ class LocalObjectStorageClient(ObjectStorageClient):
path.write_text(content, encoding="utf-8")
return f"file://{path}"
def put_bytes(
self,
*,
object_key: str,
content: bytes,
content_type: str = "application/octet-stream",
) -> str:
del content_type
path = self.root / object_key
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
return f"file://{path}"
class S3ObjectStorageClient(ObjectStorageClient):
def __init__(self) -> None:
@@ -36,11 +58,24 @@ class S3ObjectStorageClient(ObjectStorageClient):
)
def put_text(self, *, object_key: str, content: str) -> str:
return self.put_bytes(
object_key=object_key,
content=content.encode("utf-8"),
content_type="application/json; charset=utf-8",
)
def put_bytes(
self,
*,
object_key: str,
content: bytes,
content_type: str = "application/octet-stream",
) -> str:
self.client.put_object(
Bucket=self.bucket,
Key=object_key,
Body=content.encode("utf-8"),
ContentType="application/json; charset=utf-8",
Body=content,
ContentType=content_type,
)
return f"s3://{self.bucket}/{object_key}"
@@ -13,6 +13,14 @@ from src.domain.contracts import (
AgentJobStatus,
AgentJobSummary,
AgentJobType,
ContentReviewKind,
ContentReviewIssueSummary,
ContentReviewReportSummary,
ContentReviewSuggestionSummary,
AssetRevisionSummary,
AssetStatus,
AssetSummary,
AssetType,
ArticleSummary,
BoundaryQuestionSummary,
ClaimRiskLevel,
@@ -29,6 +37,7 @@ from src.domain.contracts import (
ResearchArtifactManifestSummary,
ResearchArtifactSummary,
ArticleWorkflowStatus,
ReviewSuggestionStatus,
WorkflowEventSummary,
Role,
ScriptConfigVersionStatus,
@@ -55,6 +64,8 @@ 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)
self.claims = ClaimsRepository(self)
@@ -1192,6 +1203,737 @@ 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
def create(
self,
*,
article_id: UUID,
section_id: UUID | None,
asset_type: AssetType,
title: str,
prompt: str | None,
object_key: str | None,
file_url: str | None,
alt_text: str | None,
caption: str | None,
status: AssetStatus,
created_at: datetime,
updated_at: datetime,
) -> AssetSummary:
asset_id = uuid4()
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
connection.execute(
f"""
INSERT INTO assets (
id,
article_id,
section_id,
asset_type,
title,
prompt,
object_key,
file_url,
alt_text,
caption,
status,
created_at,
updated_at
)
VALUES (
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder}
)
""",
(
str(asset_id),
str(article_id),
_uuid_value(section_id),
asset_type.value,
title,
prompt,
object_key,
file_url,
alt_text,
caption,
status.value,
_datetime_value(created_at),
_datetime_value(updated_at),
),
)
return self.get(article_id=article_id, asset_id=asset_id)
def list_for_article(self, article_id: UUID) -> list[AssetSummary]:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
rows = connection.execute(
f"""
SELECT
id,
article_id,
section_id,
asset_type,
title,
prompt,
object_key,
file_url,
alt_text,
caption,
status,
created_at,
updated_at
FROM assets
WHERE article_id = {placeholder}
ORDER BY created_at, id
""",
(str(article_id),),
).fetchall()
history_by_asset = self._history_by_asset(article_id)
assets: list[AssetSummary] = []
for row in rows:
asset = _asset_summary_from_row(
row,
history=history_by_asset.get(str(_row_value(row, "id")), []),
)
assets.append(asset)
return assets
def get(self, *, article_id: UUID, asset_id: UUID) -> AssetSummary:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT
id,
article_id,
section_id,
asset_type,
title,
prompt,
object_key,
file_url,
alt_text,
caption,
status,
created_at,
updated_at
FROM assets
WHERE article_id = {placeholder} AND id = {placeholder}
""",
(str(article_id), str(asset_id)),
).fetchone()
if row is None:
raise LookupError(f"Asset not found: {asset_id}")
history_by_asset = self._history_by_asset(article_id)
return _asset_summary_from_row(
row,
history=history_by_asset.get(str(asset_id), []),
)
def update(
self,
*,
article_id: UUID,
asset_id: UUID,
section_id: UUID | None,
title: str,
prompt: str | None,
object_key: str | None,
file_url: str | None,
alt_text: str | None,
caption: str | None,
status: AssetStatus,
updated_at: datetime,
) -> AssetSummary:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
connection.execute(
f"""
UPDATE assets
SET
section_id = {placeholder},
title = {placeholder},
prompt = {placeholder},
object_key = {placeholder},
file_url = {placeholder},
alt_text = {placeholder},
caption = {placeholder},
status = {placeholder},
updated_at = {placeholder}
WHERE article_id = {placeholder} AND id = {placeholder}
""",
(
_uuid_value(section_id),
title,
prompt,
object_key,
file_url,
alt_text,
caption,
status.value,
_datetime_value(updated_at),
str(article_id),
str(asset_id),
),
)
return self.get(article_id=article_id, asset_id=asset_id)
def next_revision_index(self, *, article_id: UUID, asset_id: UUID) -> int:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT COALESCE(MAX(revision_index), 0) + 1 AS next_revision
FROM asset_revisions
WHERE article_id = {placeholder} AND asset_id = {placeholder}
""",
(str(article_id), str(asset_id)),
).fetchone()
return int(_row_value(row, "next_revision"))
def create_revision(
self,
*,
article_id: UUID,
asset_id: UUID,
revision_index: int,
action: str,
actor_user_id: UUID | None,
payload: JsonObject,
created_at: datetime,
) -> AssetRevisionSummary:
revision_id = uuid4()
placeholder = self._repository.placeholder()
json_cast = self._repository.json_cast()
with self._repository.connection() as connection:
connection.execute(
f"""
INSERT INTO asset_revisions (
id,
article_id,
asset_id,
revision_index,
action,
actor_user_id,
payload,
created_at
)
VALUES (
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder}{json_cast},
{placeholder}
)
""",
(
str(revision_id),
str(article_id),
str(asset_id),
revision_index,
action,
_uuid_value(actor_user_id),
_json_value(payload),
_datetime_value(created_at),
),
)
return self.get_revision(
article_id=article_id,
asset_id=asset_id,
revision_id=revision_id,
)
def get_revision(
self,
*,
article_id: UUID,
asset_id: UUID,
revision_id: UUID,
) -> AssetRevisionSummary:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT
id,
article_id,
asset_id,
revision_index,
action,
actor_user_id,
payload,
created_at
FROM asset_revisions
WHERE article_id = {placeholder}
AND asset_id = {placeholder}
AND id = {placeholder}
""",
(str(article_id), str(asset_id), str(revision_id)),
).fetchone()
if row is None:
raise LookupError(f"Asset revision not found: {revision_id}")
return _asset_revision_from_row(row)
def _history_by_asset(self, article_id: UUID) -> dict[str, list[AssetRevisionSummary]]:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
rows = connection.execute(
f"""
SELECT
id,
article_id,
asset_id,
revision_index,
action,
actor_user_id,
payload,
created_at
FROM asset_revisions
WHERE article_id = {placeholder}
ORDER BY revision_index, created_at, id
""",
(str(article_id),),
).fetchall()
history: dict[str, list[AssetRevisionSummary]] = {}
for row in rows:
asset_id = str(_row_value(row, "asset_id"))
history.setdefault(asset_id, []).append(_asset_revision_from_row(row))
return history
class ResearchManifestsRepository:
def __init__(self, repository: BackendRepository) -> None:
self._repository = repository
@@ -2383,6 +3125,82 @@ 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"),
article_id=_row_value(row, "article_id"),
asset_id=_row_value(row, "asset_id"),
revision_index=_row_value(row, "revision_index"),
action=_row_value(row, "action"),
actor_user_id=_row_value(row, "actor_user_id"),
payload=_json_from_row(row, "payload"),
created_at=_row_value(row, "created_at"),
)
def _asset_summary_from_row(
row: Any,
*,
history: list[AssetRevisionSummary] | None = None,
) -> AssetSummary:
return AssetSummary(
id=_row_value(row, "id"),
article_id=_row_value(row, "article_id"),
section_id=_row_value(row, "section_id"),
asset_type=_row_value(row, "asset_type"),
title=_row_value(row, "title"),
prompt=_row_value(row, "prompt"),
object_key=_row_value(row, "object_key"),
file_url=_row_value(row, "file_url"),
alt_text=_row_value(row, "alt_text"),
caption=_row_value(row, "caption"),
status=_row_value(row, "status"),
history=history or [],
created_at=_row_value(row, "created_at"),
updated_at=_row_value(row, "updated_at"),
)
def _agent_job_summary_from_row(row: Any) -> AgentJobSummary:
return AgentJobSummary(
id=_row_value(row, "id"),
+124
View File
@@ -233,13 +233,50 @@ 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(),
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
section_id UUID REFERENCES plan_sections(id) ON DELETE SET NULL,
asset_type TEXT NOT NULL CHECK (asset_type IN ({ASSET_TYPE_VALUES})),
title TEXT NOT NULL,
prompt TEXT,
object_key TEXT,
file_url TEXT,
alt_text TEXT,
caption TEXT,
@@ -248,6 +285,20 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
"ALTER TABLE assets ADD COLUMN IF NOT EXISTS section_id UUID REFERENCES plan_sections(id) ON DELETE SET NULL",
"ALTER TABLE assets ADD COLUMN IF NOT EXISTS object_key TEXT",
f"""
CREATE TABLE IF NOT EXISTS asset_revisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
revision_index INTEGER NOT NULL,
action TEXT NOT NULL,
actor_user_id UUID REFERENCES users(id),
payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
f"""
CREATE TABLE IF NOT EXISTS workflow_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -370,6 +421,10 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
ON workflow_events (article_id, created_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_asset_revisions_asset
ON asset_revisions (asset_id, revision_index, created_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_research_run_manifests_article
ON research_run_manifests (article_id, created_at)
""",
@@ -381,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)
""",
)
@@ -550,12 +613,49 @@ 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,
section_id TEXT,
asset_type TEXT NOT NULL,
title TEXT NOT NULL,
prompt TEXT,
object_key TEXT,
file_url TEXT,
alt_text TEXT,
caption TEXT,
@@ -565,6 +665,18 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
)
""",
"""
CREATE TABLE IF NOT EXISTS asset_revisions (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
asset_id TEXT NOT NULL,
revision_index INTEGER NOT NULL,
action TEXT NOT NULL,
actor_user_id TEXT,
payload TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS workflow_events (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
@@ -676,6 +788,10 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
ON workflow_events (article_id, created_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_asset_revisions_asset
ON asset_revisions (asset_id, revision_index, created_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_research_run_manifests_article
ON research_run_manifests (article_id, created_at)
""",
@@ -687,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)
""",
)
+4
View File
@@ -10,21 +10,25 @@ from src.presentation.routes.agent_jobs import (
)
from src.presentation.routes.agent_jobs import router as agent_jobs_router
from src.presentation.routes.articles import router as articles_router
from src.presentation.routes.assets import router as assets_router
from src.presentation.routes.auth import router as auth_router
from src.presentation.routes.boundary_questions import router as boundary_questions_router
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
app = FastAPI(title="AI Content Pipeline Backend")
app.include_router(auth_router)
app.include_router(articles_router)
app.include_router(assets_router)
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,149 @@
from __future__ import annotations
import base64
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from src.application.assets import (
approve_asset,
generate_asset_specs,
list_assets,
update_asset,
upload_asset_file,
)
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
AssetGenerateSpecsResponse,
AssetListResponse,
AssetResponse,
AssetUpdateRequest,
AssetUploadRequest,
AssetUploadResponse,
CurrentUser,
)
from src.infrastructure.object_storage import open_object_storage_client
from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles
router = APIRouter(prefix="/api", tags=["assets"])
@router.post(
"/articles/{article_id}/assets/generate-specs",
response_model=AssetGenerateSpecsResponse,
status_code=status.HTTP_201_CREATED,
)
def post_generate_asset_specs(
article_id: UUID,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AssetGenerateSpecsResponse:
try:
return generate_asset_specs(
repository,
article_id=article_id,
current_user=current_user,
)
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}/assets",
response_model=AssetListResponse,
)
def get_article_assets(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AssetListResponse:
try:
return list_assets(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.patch(
"/articles/{article_id}/assets/{asset_id}",
response_model=AssetResponse,
)
def patch_article_asset(
article_id: UUID,
asset_id: UUID,
request: AssetUpdateRequest,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AssetResponse:
try:
return update_asset(
repository,
article_id=article_id,
asset_id=asset_id,
request=request,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/assets/{asset_id}/approve",
response_model=AssetResponse,
)
def post_approve_asset(
article_id: UUID,
asset_id: UUID,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AssetResponse:
try:
return approve_asset(
repository,
article_id=article_id,
asset_id=asset_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/assets/{asset_id}/upload",
response_model=AssetUploadResponse,
)
def post_upload_asset_file(
article_id: UUID,
asset_id: UUID,
request: AssetUploadRequest,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AssetUploadResponse:
try:
payload = base64.b64decode(request.content_base64.encode("utf-8"), validate=True)
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Invalid base64 payload",
) from error
if not payload:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Uploaded file is empty",
)
try:
return upload_asset_file(
repository,
article_id=article_id,
asset_id=asset_id,
filename=request.filename,
content_type=request.content_type,
content=payload,
current_user=current_user,
object_storage=open_object_storage_client(),
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@@ -0,0 +1,244 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from src.application.reviews import (
accept_suggestion,
edit_suggestion,
get_final_review_issues,
get_language_report,
get_seo_report,
reject_suggestion,
run_language_review,
run_seo_review,
)
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
ContentReviewKind,
CurrentUser,
FinalReviewIssuesResponse,
LanguageReviewReportResponse,
LanguageReviewRunResponse,
ReviewSuggestionActionResponse,
ReviewSuggestionEditRequest,
SeoReviewReportResponse,
SeoReviewRunResponse,
)
from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles
router = APIRouter(prefix="/api", tags=["reviews"])
@router.post(
"/articles/{article_id}/seo/review",
response_model=SeoReviewRunResponse,
status_code=status.HTTP_201_CREATED,
)
def post_run_seo_review(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> SeoReviewRunResponse:
try:
return run_seo_review(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except PermissionError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.get(
"/articles/{article_id}/seo/report",
response_model=SeoReviewReportResponse,
)
def get_article_seo_report(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> SeoReviewReportResponse:
try:
return get_seo_report(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/language/review",
response_model=LanguageReviewRunResponse,
status_code=status.HTTP_201_CREATED,
)
def post_run_language_review(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> LanguageReviewRunResponse:
try:
return run_language_review(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except PermissionError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.get(
"/articles/{article_id}/language/report",
response_model=LanguageReviewReportResponse,
)
def get_article_language_report(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> LanguageReviewReportResponse:
try:
return get_language_report(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/seo/suggestions/{suggestion_id}/accept",
response_model=ReviewSuggestionActionResponse,
)
def post_accept_seo_suggestion(
article_id: UUID,
suggestion_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ReviewSuggestionActionResponse:
try:
return accept_suggestion(
repository,
article_id=article_id,
review_kind=ContentReviewKind.SEO,
suggestion_id=suggestion_id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/seo/suggestions/{suggestion_id}/reject",
response_model=ReviewSuggestionActionResponse,
)
def post_reject_seo_suggestion(
article_id: UUID,
suggestion_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ReviewSuggestionActionResponse:
try:
return reject_suggestion(
repository,
article_id=article_id,
review_kind=ContentReviewKind.SEO,
suggestion_id=suggestion_id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/seo/suggestions/{suggestion_id}/edit",
response_model=ReviewSuggestionActionResponse,
)
def post_edit_seo_suggestion(
article_id: UUID,
suggestion_id: UUID,
request: ReviewSuggestionEditRequest,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ReviewSuggestionActionResponse:
try:
return edit_suggestion(
repository,
article_id=article_id,
review_kind=ContentReviewKind.SEO,
suggestion_id=suggestion_id,
request=request,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/language/suggestions/{suggestion_id}/accept",
response_model=ReviewSuggestionActionResponse,
)
def post_accept_language_suggestion(
article_id: UUID,
suggestion_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ReviewSuggestionActionResponse:
try:
return accept_suggestion(
repository,
article_id=article_id,
review_kind=ContentReviewKind.LANGUAGE,
suggestion_id=suggestion_id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/language/suggestions/{suggestion_id}/reject",
response_model=ReviewSuggestionActionResponse,
)
def post_reject_language_suggestion(
article_id: UUID,
suggestion_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ReviewSuggestionActionResponse:
try:
return reject_suggestion(
repository,
article_id=article_id,
review_kind=ContentReviewKind.LANGUAGE,
suggestion_id=suggestion_id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/language/suggestions/{suggestion_id}/edit",
response_model=ReviewSuggestionActionResponse,
)
def post_edit_language_suggestion(
article_id: UUID,
suggestion_id: UUID,
request: ReviewSuggestionEditRequest,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ReviewSuggestionActionResponse:
try:
return edit_suggestion(
repository,
article_id=article_id,
review_kind=ContentReviewKind.LANGUAGE,
suggestion_id=suggestion_id,
request=request,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.get(
"/articles/{article_id}/final-review/issues",
response_model=FinalReviewIssuesResponse,
)
def get_article_final_review_issues(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> FinalReviewIssuesResponse:
try:
return get_final_review_issues(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@@ -0,0 +1,366 @@
from __future__ import annotations
import base64
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_USER_EMAIL_HEADER = "X-Demo-User-Email"
class AssetsMediaLibraryPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
self.objects_root = Path(self.tmp_dir.name) / "objects"
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(self.objects_root)
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'assets-media-library.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_generate_specs_links_assets_to_approved_plan_sections(self) -> None:
article_id, plan = self._prepare_article_with_approved_plan()
response = self.client.post(
f"/api/articles/{article_id}/assets/generate-specs",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, response.status_code, response.text)
assets = response.json()["assets"]
self.assertEqual(len(plan["sections"]), len(assets))
section_ids = {section["id"] for section in plan["sections"]}
for asset in assets:
self.assertIn(asset["section_id"], section_ids)
self.assertEqual("GENERATED", asset["status"])
self.assertTrue(asset["title"])
def test_upload_stores_object_and_persists_object_key(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
asset_id = self._generate_specs(article_id)[0]["id"]
upload_response = self.client.post(
f"/api/articles/{article_id}/assets/{asset_id}/upload",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"filename": "hero.png",
"content_base64": base64.b64encode(b"fake-binary-image-content").decode("utf-8"),
"content_type": "image/png",
},
)
self.assertEqual(200, upload_response.status_code, upload_response.text)
asset = upload_response.json()["asset"]
self.assertEqual("PENDING", asset["status"])
self.assertTrue(asset["object_key"])
self.assertTrue(asset["file_url"].startswith("file://"))
self.assertTrue(
(self.objects_root / asset["object_key"]).exists(),
"Uploaded object must exist in local object storage",
)
persisted = (self.objects_root / asset["object_key"]).read_bytes()
self.assertEqual(b"fake-binary-image-content", persisted)
history = asset["history"]
self.assertGreaterEqual(len(history), 1)
self.assertEqual("ASSET_FILE_UPLOADED", history[-1]["action"])
def test_approve_asset_writes_workflow_event(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
asset_id = self._generate_specs(article_id)[0]["id"]
self._upload_asset(article_id, asset_id, filename="approve.png", payload=b"approve")
approve_response = self.client.post(
f"/api/articles/{article_id}/assets/{asset_id}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, approve_response.status_code, approve_response.text)
self.assertEqual("APPROVED", approve_response.json()["asset"]["status"])
article_detail = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, article_detail.status_code, article_detail.text)
events = article_detail.json()["workflow_events"]
approval_events = [event for event in events if event["event_type"] == "ASSET_APPROVED"]
self.assertTrue(approval_events, "Approval must write workflow event")
self.assertEqual(asset_id, approval_events[-1]["payload"]["asset_id"])
def test_rejected_assets_are_excluded_from_draft_publish_bundle(self) -> None:
article_id = self._prepare_article_with_completed_scaffolds()
generated_assets = self._generate_specs(article_id)
self.assertGreaterEqual(len(generated_assets), 2)
approved_asset = generated_assets[0]
rejected_asset = generated_assets[1]
approved_upload = self._upload_asset(
article_id,
approved_asset["id"],
filename="approved.png",
payload=b"approved-image",
)
self._approve_asset(article_id, approved_asset["id"])
rejected_upload = self._upload_asset(
article_id,
rejected_asset["id"],
filename="rejected.png",
payload=b"rejected-image",
)
self._patch_asset(
article_id,
rejected_asset["id"],
request={"status": "REJECTED"},
)
draft = self._assemble_draft(article_id)
self.assertIn(approved_upload["file_url"], draft["body_markdown"])
self.assertNotIn(rejected_upload["file_url"], draft["body_markdown"])
self.assertIn(approved_upload["file_url"], "\n".join(draft["visual_placeholders"]))
self.assertNotIn(rejected_upload["file_url"], "\n".join(draft["visual_placeholders"]))
def test_replacement_preserves_audit_history(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
asset_id = self._generate_specs(article_id)[0]["id"]
first = self._upload_asset(article_id, asset_id, filename="first.png", payload=b"first")
second = self._upload_asset(article_id, asset_id, filename="second.png", payload=b"second")
self.assertNotEqual(first["object_key"], second["object_key"])
list_response = self.client.get(
f"/api/articles/{article_id}/assets",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, list_response.status_code, list_response.text)
asset = next(item for item in list_response.json()["assets"] if item["id"] == asset_id)
history = asset["history"]
actions = [item["action"] for item in history]
self.assertIn("ASSET_FILE_UPLOADED", actions)
self.assertIn("ASSET_FILE_REPLACED", actions)
replaced_event = next(item for item in history if item["action"] == "ASSET_FILE_REPLACED")
self.assertEqual(first["object_key"], replaced_event["payload"]["previous_object_key"])
self.assertEqual(second["object_key"], replaced_event["payload"]["next_object_key"])
def test_media_library_lists_assets_with_required_fields(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
asset_id = self._generate_specs(article_id)[0]["id"]
self._upload_asset(article_id, asset_id, filename="library.png", payload=b"library")
self._patch_asset(
article_id,
asset_id,
request={
"title": "Library Hero",
"alt_text": "Dashboard on laptop screen",
"caption": "Screenshot used in introduction section.",
},
)
list_response = self.client.get(
f"/api/articles/{article_id}/assets",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, list_response.status_code, list_response.text)
asset = next(item for item in list_response.json()["assets"] if item["id"] == asset_id)
self.assertEqual("Library Hero", asset["title"])
self.assertTrue(asset["asset_type"])
self.assertTrue(asset["status"])
self.assertTrue(asset["file_url"])
self.assertEqual("Dashboard on laptop screen", asset["alt_text"])
self.assertEqual("Screenshot used in introduction section.", asset["caption"])
def test_draft_preview_uses_approved_asset_references(self) -> None:
article_id = self._prepare_article_with_completed_scaffolds()
asset = self._generate_specs(article_id)[0]
uploaded = self._upload_asset(
article_id,
asset["id"],
filename="preview.png",
payload=b"preview",
)
self._approve_asset(article_id, asset["id"])
draft = self._assemble_draft(article_id)
self.assertIn(uploaded["file_url"], draft["body_markdown"])
self.assertIn(uploaded["file_url"], "\n".join(draft["visual_placeholders"]))
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"]
def _upload_asset(self, article_id: UUID, asset_id: str, *, filename: str, payload: bytes) -> dict[str, Any]:
response = self.client.post(
f"/api/articles/{article_id}/assets/{asset_id}/upload",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"filename": filename,
"content_base64": base64.b64encode(payload).decode("utf-8"),
"content_type": "image/png",
},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["asset"]
def _approve_asset(self, article_id: UUID, asset_id: str) -> None:
response = self.client.post(
f"/api/articles/{article_id}/assets/{asset_id}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, response.status_code, response.text)
def _patch_asset(self, article_id: UUID, asset_id: str, *, request: dict[str, Any]) -> dict[str, Any]:
response = self.client.patch(
f"/api/articles/{article_id}/assets/{asset_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json=request,
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["asset"]
def _generate_specs(self, article_id: UUID) -> list[dict[str, Any]]:
response = self.client.post(
f"/api/articles/{article_id}/assets/generate-specs",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, response.status_code, response.text)
return response.json()["assets"]
def _prepare_article_with_assembled_draft(self) -> UUID:
article_id = self._prepare_article_with_completed_scaffolds()
self._assemble_draft(article_id)
return article_id
def _prepare_article_with_completed_scaffolds(self) -> UUID:
article_id, _ = self._prepare_article_with_approved_plan()
self._ensure_evidence_ready(article_id)
section_jobs = self._start_parallel_production(article_id)
self._complete_all_section_jobs_success(section_jobs)
return article_id
def _prepare_article_with_approved_plan(self) -> tuple[UUID, dict[str, Any]]:
site = self.client.get(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()[0]["site"]
article = self.client.post(
"/api/articles",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"target_site_id": site["id"],
"brief_description": "Assets library integration coverage",
"working_title": "Assets and Media Library",
"content_type": "longform_guide",
"primary_keyword": "assets media library",
},
).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"]
self.assertGreaterEqual(len(plan["sections"]), 1)
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 _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 _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 _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"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,431 @@
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi.testclient import TestClient
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
from src.application.seed_data import seed_reference_data # noqa: E402
from src.infrastructure.repositories import open_backend_repository # noqa: E402
from src.presentation.dependencies import get_repository # noqa: E402
from src.presentation.main import app # noqa: E402
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_ADMIN_EMAIL = "admin@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class SeoLanguageReviewPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects")
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'seo-language-review.db'}"
self.repository = open_backend_repository(dsn)
self.repository.setup()
seed_reference_data(self.repository)
app.dependency_overrides[get_repository] = lambda: self.repository
self.client = TestClient(app)
def tearDown(self) -> None:
app.dependency_overrides.clear()
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
self.tmp_dir.cleanup()
def test_seo_report_includes_required_fields(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
self._run_seo_review(article_id)
report = self._get_seo_report(article_id)
self.assertIn("score", report)
self.assertIn("issues", report)
self.assertIn("recommended_slug", report)
self.assertIn("recommended_title", report)
self.assertIn("schema_json", report)
self.assertGreaterEqual(len(report["issues"]), 1)
first_issue = report["issues"][0]
self.assertIn("severity", first_issue)
self.assertIn("location", first_issue)
self.assertIn("message", first_issue)
self.assertIn("suggested_fix", first_issue)
self.assertIn("suggestion_id", first_issue)
def test_language_report_includes_required_fields(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
self._seed_language_problematic_draft(article_id)
self._run_language_review(article_id)
report = self._get_language_report(article_id)
self.assertGreaterEqual(len(report["issues"]), 1)
self.assertTrue(any(issue.get("suggested_rewrite") for issue in report["issues"]))
first_issue = report["issues"][0]
self.assertIn("severity", first_issue)
self.assertIn("location", first_issue)
self.assertIn("message", first_issue)
self.assertIn("suggested_rewrite", first_issue)
def test_editor_actions_accept_reject_edit_and_immutable_drafts(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
self._seed_language_problematic_draft(article_id)
self._run_seo_review(article_id)
self._run_language_review(article_id)
seo_report = self._get_seo_report(article_id)
language_report = self._get_language_report(article_id)
base_latest = self._latest_draft(article_id)
seo_patch_issue = next(
issue for issue in seo_report["issues"] if issue["location"] == "meta_description"
)
accept_response = self.client.post(
f"/api/articles/{article_id}/seo/suggestions/{seo_patch_issue['suggestion_id']}/accept",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, accept_response.status_code, accept_response.text)
accepted = accept_response.json()
self.assertEqual("ACCEPTED", accepted["suggestion"]["status"])
self.assertEqual(base_latest["version"] + 1, accepted["draft"]["version"])
seo_non_patch_issue = next(
issue for issue in seo_report["issues"] if issue["location"] == "body_markdown"
)
reject_response = self.client.post(
f"/api/articles/{article_id}/seo/suggestions/{seo_non_patch_issue['suggestion_id']}/reject",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, reject_response.status_code, reject_response.text)
self.assertEqual("REJECTED", reject_response.json()["suggestion"]["status"])
self.assertIsNone(reject_response.json()["draft"])
language_rewrite_issue = next(
issue for issue in language_report["issues"] if "Sentence exceeds" in issue["message"]
)
edit_response = self.client.post(
f"/api/articles/{article_id}/language/suggestions/{language_rewrite_issue['suggestion_id']}/edit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"applied_text": "This sentence now states the same idea clearly."},
)
self.assertEqual(200, edit_response.status_code, edit_response.text)
edited = edit_response.json()
self.assertEqual("EDITED", edited["suggestion"]["status"])
self.assertEqual(
accepted["draft"]["version"] + 1,
edited["draft"]["version"],
)
self.assertIn("states the same idea clearly", edited["draft"]["body_markdown"])
def test_target_site_overrides_are_applied(self) -> None:
site = self.client.get(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()[0]["site"]
patch_response = self.client.patch(
f"/api/sites/{site['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json={
"seo_rules": {
"seo_review": {
"meta_description_min": 10,
"meta_description_max": 40,
"schema_type": "HowTo",
},
"language_review": {
"max_sentence_words": 8,
"forbidden_phrases": ["precision tone"],
"brand_tone": "strict",
},
}
},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
article_id = self._prepare_article_with_assembled_draft(target_site_id=UUID(site["id"]))
self._seed_language_problematic_draft(
article_id,
extra_body="This precision tone phrase should be avoided!",
)
self._run_seo_review(article_id)
self._run_language_review(article_id)
seo_report = self._get_seo_report(article_id)
self.assertEqual("HowTo", seo_report["schema_json"]["@type"])
language_report = self._get_language_report(article_id)
self.assertEqual(8, language_report["rules_snapshot"]["max_sentence_words"])
self.assertEqual("strict", language_report["rules_snapshot"]["brand_tone"])
self.assertTrue(
any("8 words" in issue["message"] for issue in language_report["issues"])
)
self.assertTrue(
any("Forbidden phrase" in issue["message"] for issue in language_report["issues"])
)
def test_final_review_shows_unresolved_issues(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
self._seed_language_problematic_draft(article_id)
self._run_seo_review(article_id)
self._run_language_review(article_id)
seo_report = self._get_seo_report(article_id)
issue_to_accept = seo_report["issues"][0]
accept_response = self.client.post(
f"/api/articles/{article_id}/seo/suggestions/{issue_to_accept['suggestion_id']}/accept",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, accept_response.status_code, accept_response.text)
unresolved_response = self.client.get(
f"/api/articles/{article_id}/final-review/issues",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, unresolved_response.status_code, unresolved_response.text)
unresolved = unresolved_response.json()
self.assertGreaterEqual(unresolved["unresolved_count"], 1)
self.assertTrue(unresolved["unresolved_issues"])
self.assertTrue(
all(item["status"] == "PENDING" for item in unresolved["unresolved_issues"])
)
def test_fake_runner_is_deterministic(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
self._seed_language_problematic_draft(article_id)
self._run_seo_review(article_id)
first = self._get_seo_report(article_id)
self._run_seo_review(article_id)
second = self._get_seo_report(article_id)
self.assertEqual(first["score"], second["score"])
self.assertEqual(first["recommended_slug"], second["recommended_slug"])
self.assertEqual(first["recommended_title"], second["recommended_title"])
first_issue_signature = [
(
issue["id"],
issue["severity"],
issue["location"],
issue["message"],
issue["suggested_fix"],
issue["suggested_rewrite"],
)
for issue in first["issues"]
]
second_issue_signature = [
(
issue["id"],
issue["severity"],
issue["location"],
issue["message"],
issue["suggested_fix"],
issue["suggested_rewrite"],
)
for issue in second["issues"]
]
self.assertEqual(first_issue_signature, second_issue_signature)
def _run_seo_review(self, article_id: UUID) -> dict[str, Any]:
response = self.client.post(
f"/api/articles/{article_id}/seo/review",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, response.status_code, response.text)
return response.json()["report"]
def _run_language_review(self, article_id: UUID) -> dict[str, Any]:
response = self.client.post(
f"/api/articles/{article_id}/language/review",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, response.status_code, response.text)
return response.json()["report"]
def _get_seo_report(self, article_id: UUID) -> dict[str, Any]:
response = self.client.get(
f"/api/articles/{article_id}/seo/report",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["report"]
def _get_language_report(self, article_id: UUID) -> dict[str, Any]:
response = self.client.get(
f"/api/articles/{article_id}/language/report",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["report"]
def _latest_draft(self, article_id: UUID) -> dict[str, Any]:
response = self.client.get(
f"/api/articles/{article_id}/drafts",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["drafts"][0]
def _seed_language_problematic_draft(
self,
article_id: UUID,
*,
extra_body: str = "",
) -> None:
latest = self._latest_draft(article_id)
body = "\n".join(
[
latest["body_markdown"],
(
"This sentence is intentionally extremely long and includes many additional words "
"that make it exceed any practical editorial threshold for readability while still "
"describing the same guidance in an unnecessarily verbose and repetitive way for testing."
),
"Our approach is the best ever for everyone.",
extra_body,
]
)
patch_response = self.client.patch(
f"/api/articles/{article_id}/drafts/{latest['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"body_markdown": body},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
def _prepare_article_with_assembled_draft(
self,
*,
target_site_id: UUID | None = None,
) -> UUID:
article_id, _ = self._prepare_article_with_approved_plan(target_site_id=target_site_id)
self._ensure_evidence_ready(article_id)
section_jobs = self._start_parallel_production(article_id)
self._complete_all_section_jobs_success(section_jobs)
self._assemble_draft(article_id)
return article_id
def _prepare_article_with_approved_plan(
self,
*,
target_site_id: UUID | None = None,
) -> tuple[UUID, dict[str, Any]]:
if target_site_id is None:
site = self.client.get(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()[0]["site"]
target_site_id = UUID(site["id"])
article = self.client.post(
"/api/articles",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"target_site_id": str(target_site_id),
"brief_description": "SEO and language review implementation coverage.",
"working_title": "Short",
"content_type": "longform_guide",
"primary_keyword": "seo language review",
},
).json()["article"]
article_id = UUID(article["id"])
questions = self.client.post(
f"/api/articles/{article_id}/boundary-questions/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["questions"]
for question in questions:
if question["is_required"]:
patch_response = self.client.patch(
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"answer": f"Answer for {question['category']}"},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
submit_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/submit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, submit_response.status_code, submit_response.text)
plan_response = self.client.post(
f"/api/articles/{article_id}/plan/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, plan_response.status_code, plan_response.text)
plan = plan_response.json()["plan"]
approve_plan_response = self.client.post(
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, approve_plan_response.status_code, approve_plan_response.text)
return article_id, plan
def _ensure_evidence_ready(self, article_id: UUID) -> None:
research_response = self.client.post(
f"/api/articles/{article_id}/research/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, research_response.status_code, research_response.text)
evidence_response = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
def _start_parallel_production(self, article_id: UUID) -> list[dict[str, Any]]:
response = self.client.post(
f"/api/articles/{article_id}/draft/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(202, response.status_code, response.text)
jobs = response.json()["jobs"]
section_jobs = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"]
self.assertTrue(section_jobs)
return section_jobs
def _complete_all_section_jobs_success(self, section_jobs: list[dict[str, Any]]) -> None:
for index, job in enumerate(section_jobs, start=1):
complete_response = self.client.post(
f"/internal/agent-jobs/{job['id']}/complete",
json={
"workspace_path": f"/tmp/{job['id']}",
"stdout": "fake section scaffolding runner\n",
"stderr": "",
"exit_code": 0,
"duration_ms": 1,
"output": {
"status": "SUCCEEDED",
"output_files": [{"path": f"outputs/section-{index}.md"}],
"payload": {
"used_evidence_ids": job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": f"## {job['payload']['heading']}\n\nSection {index}.",
},
},
},
)
self.assertEqual(200, complete_response.status_code, complete_response.text)
self.assertEqual("SUCCEEDED", complete_response.json()["job"]["status"])
def _assemble_draft(self, article_id: UUID) -> dict[str, Any]:
response = self.client.post(
f"/api/articles/{article_id}/draft/assemble",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, response.status_code, response.text)
return response.json()["draft"]
if __name__ == "__main__":
unittest.main()
+14
View File
@@ -348,6 +348,10 @@ main {
margin-top: 28px;
}
.reviewPanel {
margin-top: 28px;
}
.draftEditor .detailGrid select {
width: 100%;
border-radius: 6px;
@@ -382,6 +386,16 @@ main {
font-size: 12px;
}
.reviewActions {
display: grid;
grid-template-columns: repeat(2, minmax(88px, 1fr));
gap: 6px;
}
.reviewActions input {
grid-column: 1 / 3;
}
@media (max-width: 640px) {
main {
padding: 28px;
@@ -0,0 +1,71 @@
import type { AssetSummary } from "@pipeline/shared";
export type MediaLibraryRow = {
id: string;
sectionId: string | null;
status: string;
type: string;
title: string;
preview: string | null;
altText: string | null;
caption: string | null;
publishReady: boolean;
};
export function buildMediaLibraryRows(assets: readonly AssetSummary[]): MediaLibraryRow[] {
return [...assets]
.map((asset) => ({
id: asset.id,
sectionId: asset.section_id ?? null,
status: asset.status,
type: asset.asset_type,
title: asset.title,
preview: asset.file_url ?? null,
altText: asset.alt_text ?? null,
caption: asset.caption ?? null,
publishReady: asset.status === "APPROVED" && Boolean(asset.file_url),
}))
.sort((left, right) => left.title.localeCompare(right.title));
}
export function resolveDraftPreviewMarkdown(
markdown: string,
assets: readonly AssetSummary[],
): string {
if (!markdown) {
return "";
}
const approvedBySection = approvedAssetMapBySection(assets);
return markdown.replace(
/!\[([^\]]*)\]\(placeholder:\/\/([^/]+)\/(\d+)\)/g,
(_full, alt: string, sectionId: string, indexText: string) => {
const index = Number.parseInt(indexText, 10);
const sectionAssets = approvedBySection.get(sectionId) ?? [];
if (!Number.isFinite(index) || index < 1 || index > sectionAssets.length) {
return _full;
}
const asset = sectionAssets[index - 1];
const resolvedAlt = asset.alt_text?.trim() || asset.title || alt || "asset";
return `![${resolvedAlt}](${asset.file_url})`;
},
);
}
function approvedAssetMapBySection(
assets: readonly AssetSummary[],
): Map<string, AssetSummary[]> {
const map = new Map<string, AssetSummary[]>();
for (const asset of assets) {
if (!asset.section_id || asset.status !== "APPROVED" || !asset.file_url) {
continue;
}
const list = map.get(asset.section_id) ?? [];
list.push(asset);
map.set(asset.section_id, list);
}
for (const [sectionId, list] of map.entries()) {
list.sort((left, right) => left.created_at.localeCompare(right.created_at));
map.set(sectionId, list);
}
return map;
}
@@ -0,0 +1,57 @@
"use client";
import { useMemo } from "react";
import type { AssetSummary } from "@pipeline/shared";
import { buildMediaLibraryRows } from "./model";
type MediaLibraryPanelProps = {
assets: readonly AssetSummary[];
};
export function MediaLibraryPanel({ assets }: MediaLibraryPanelProps) {
const rows = useMemo(() => buildMediaLibraryRows(assets), [assets]);
return (
<section>
<h3>Media library</h3>
<table className="table">
<thead>
<tr>
<th>Title</th>
<th>Type</th>
<th>Status</th>
<th>Preview</th>
<th>Alt text</th>
<th>Caption</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>{row.title}</td>
<td>{row.type}</td>
<td>{row.status}</td>
<td>
{row.preview ? (
<a href={row.preview} target="_blank" rel="noreferrer">
file
</a>
) : (
"—"
)}
</td>
<td>{row.altText ?? "—"}</td>
<td>{row.caption ?? "—"}</td>
</tr>
))}
{rows.length === 0 ? (
<tr>
<td colSpan={6}>No assets yet.</td>
</tr>
) : null}
</tbody>
</table>
</section>
);
}
@@ -3,8 +3,10 @@
import { useMemo, useState } from "react";
import type { DraftSummary } from "@pipeline/shared";
import type { AssetSummary } from "@pipeline/shared";
import { ApiError, assembleDraft, updateDraft } from "@/shared/pipeline-api";
import { resolveDraftPreviewMarkdown } from "@/features/article-assets/model";
import {
compareDraftVersions,
parseDraftPreview,
@@ -14,6 +16,7 @@ import {
type DraftEditorPanelProps = {
articleId: string;
initialDrafts: readonly DraftSummary[];
assets: readonly AssetSummary[];
};
type DraftEditorState = {
@@ -23,7 +26,7 @@ type DraftEditorState = {
bodyMarkdown: string;
};
export function DraftEditorPanel({ articleId, initialDrafts }: DraftEditorPanelProps) {
export function DraftEditorPanel({ articleId, initialDrafts, assets }: DraftEditorPanelProps) {
const [drafts, setDrafts] = useState<DraftSummary[]>(() => [...initialDrafts]);
const [selectedDraftId, setSelectedDraftId] = useState<string | null>(
initialDrafts[0]?.id ?? null,
@@ -44,8 +47,12 @@ export function DraftEditorPanel({ articleId, initialDrafts }: DraftEditorPanelP
[drafts, compareDraftId],
);
const previewBlocks = useMemo(
() => parseDraftPreview(editor.bodyMarkdown, selectedDraft?.faq_items ?? []),
[editor.bodyMarkdown, selectedDraft],
() =>
parseDraftPreview(
resolveDraftPreviewMarkdown(editor.bodyMarkdown, assets),
selectedDraft?.faq_items ?? [],
),
[assets, editor.bodyMarkdown, selectedDraft],
);
const comparison = useMemo(() => {
if (!selectedDraft || !compareDraft || selectedDraft.id === compareDraft.id) {
@@ -0,0 +1,64 @@
import type {
ContentReviewIssueSummary,
ContentReviewReportSummary,
ContentReviewKind,
FinalReviewIssuesResponse,
} from "@pipeline/shared";
export type ReviewIssueRow = {
reviewKind: ContentReviewKind;
issueId: string;
suggestionId: string;
severity: string;
location: string;
message: string;
suggestedFix: string;
suggestedRewrite: string;
status: string;
};
export type ReviewSummary = {
unresolvedCount: number;
unresolvedTitles: string[];
};
export function buildReviewIssueRows(
seoReport: ContentReviewReportSummary | null,
languageReport: ContentReviewReportSummary | null,
): ReviewIssueRow[] {
return [
...mapIssues("SEO", seoReport?.issues ?? []),
...mapIssues("LANGUAGE", languageReport?.issues ?? []),
];
}
export function buildFinalReviewSummary(
response: FinalReviewIssuesResponse | null,
): ReviewSummary {
if (!response) {
return { unresolvedCount: 0, unresolvedTitles: [] };
}
return {
unresolvedCount: response.unresolved_count,
unresolvedTitles: (response.unresolved_issues ?? []).map(
(issue) => `${issue.review_kind}: ${issue.message}`,
),
};
}
function mapIssues(
reviewKind: ContentReviewKind,
issues: readonly ContentReviewIssueSummary[],
): ReviewIssueRow[] {
return issues.map((issue) => ({
reviewKind,
issueId: issue.id,
suggestionId: issue.suggestion_id,
severity: issue.severity,
location: issue.location,
message: issue.message,
suggestedFix: issue.suggested_fix ?? "",
suggestedRewrite: issue.suggested_rewrite ?? "",
status: issue.status,
}));
}
@@ -0,0 +1,245 @@
"use client";
import { useMemo, useState } from "react";
import type {
ContentReviewReportSummary,
FinalReviewIssuesResponse,
} from "@pipeline/shared";
import {
ApiError,
acceptLanguageSuggestion,
acceptSeoSuggestion,
editLanguageSuggestion,
editSeoSuggestion,
fetchFinalReviewIssues,
fetchLanguageReport,
fetchSeoReport,
rejectLanguageSuggestion,
rejectSeoSuggestion,
runLanguageReview,
runSeoReview,
} from "@/shared/pipeline-api";
import { buildFinalReviewSummary, buildReviewIssueRows } from "./model";
type ArticleReviewPanelProps = {
articleId: string;
};
export function ArticleReviewPanel({ articleId }: ArticleReviewPanelProps) {
const [seoReport, setSeoReport] = useState<ContentReviewReportSummary | null>(null);
const [languageReport, setLanguageReport] = useState<ContentReviewReportSummary | null>(null);
const [finalIssues, setFinalIssues] = useState<FinalReviewIssuesResponse | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
const [message, setMessage] = useState("");
const [edits, setEdits] = useState<Record<string, string>>({});
const issueRows = useMemo(
() => buildReviewIssueRows(seoReport, languageReport),
[seoReport, languageReport],
);
const summary = useMemo(() => buildFinalReviewSummary(finalIssues), [finalIssues]);
async function refreshAll() {
const [seo, language, unresolved] = await Promise.all([
fetchSeoReport(articleId).catch(() => null),
fetchLanguageReport(articleId).catch(() => null),
fetchFinalReviewIssues(articleId).catch(() => null),
]);
setSeoReport(seo?.report ?? null);
setLanguageReport(language?.report ?? null);
setFinalIssues(unresolved);
}
async function onRunSeo() {
setBusyKey("seo-run");
setMessage("");
try {
const response = await runSeoReview(articleId);
setSeoReport(response.report);
await refreshAll();
setMessage(`SEO review completed with score ${response.report.score}.`);
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "SEO review failed.");
} finally {
setBusyKey(null);
}
}
async function onRunLanguage() {
setBusyKey("language-run");
setMessage("");
try {
const response = await runLanguageReview(articleId);
setLanguageReport(response.report);
await refreshAll();
setMessage(`Language review completed with score ${response.report.score}.`);
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Language review failed.");
} finally {
setBusyKey(null);
}
}
async function onAccept(kind: "SEO" | "LANGUAGE", suggestionId: string) {
setBusyKey(`accept-${suggestionId}`);
setMessage("");
try {
const response =
kind === "SEO"
? await acceptSeoSuggestion(articleId, suggestionId)
: await acceptLanguageSuggestion(articleId, suggestionId);
await refreshAll();
setMessage(
response.draft
? `Suggestion accepted. Draft v${response.draft.version} created.`
: "Suggestion accepted.",
);
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Accept failed.");
} finally {
setBusyKey(null);
}
}
async function onReject(kind: "SEO" | "LANGUAGE", suggestionId: string) {
setBusyKey(`reject-${suggestionId}`);
setMessage("");
try {
if (kind === "SEO") {
await rejectSeoSuggestion(articleId, suggestionId);
} else {
await rejectLanguageSuggestion(articleId, suggestionId);
}
await refreshAll();
setMessage("Suggestion rejected.");
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Reject failed.");
} finally {
setBusyKey(null);
}
}
async function onEdit(kind: "SEO" | "LANGUAGE", suggestionId: string) {
const appliedText = (edits[suggestionId] ?? "").trim();
if (!appliedText) {
setMessage("Provide edited text before applying.");
return;
}
setBusyKey(`edit-${suggestionId}`);
setMessage("");
try {
const response =
kind === "SEO"
? await editSeoSuggestion(articleId, suggestionId, { applied_text: appliedText })
: await editLanguageSuggestion(articleId, suggestionId, { applied_text: appliedText });
await refreshAll();
setMessage(
response.draft
? `Suggestion edited. Draft v${response.draft.version} created.`
: "Suggestion edited.",
);
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Edit failed.");
} finally {
setBusyKey(null);
}
}
return (
<section className="reviewPanel">
<div className="sectionHeader">
<div>
<h2>SEO and language review</h2>
<p>Unresolved issues: {summary.unresolvedCount}</p>
</div>
<div className="formActions">
<button type="button" onClick={onRunSeo} disabled={busyKey !== null}>
{busyKey === "seo-run" ? "Running..." : "Run SEO review"}
</button>
<button type="button" onClick={onRunLanguage} disabled={busyKey !== null}>
{busyKey === "language-run" ? "Running..." : "Run language review"}
</button>
<button type="button" onClick={() => void refreshAll()} disabled={busyKey !== null}>
Refresh
</button>
</div>
</div>
{message ? <p className={message.includes("failed") ? "formError" : "formSuccess"}>{message}</p> : null}
{summary.unresolvedTitles.length > 0 ? (
<ul className="timeline">
{summary.unresolvedTitles.map((title) => (
<li key={title}>{title}</li>
))}
</ul>
) : null}
<table className="table">
<thead>
<tr>
<th>Kind</th>
<th>Severity</th>
<th>Location</th>
<th>Message</th>
<th>Suggestion</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{issueRows.map((row) => (
<tr key={`${row.reviewKind}-${row.suggestionId}`}>
<td>{row.reviewKind}</td>
<td>{row.severity}</td>
<td>{row.location}</td>
<td>{row.message}</td>
<td>{row.suggestedRewrite || row.suggestedFix || "—"}</td>
<td>{row.status}</td>
<td>
<div className="reviewActions">
<button
type="button"
onClick={() => void onAccept(row.reviewKind, row.suggestionId)}
disabled={busyKey !== null}
>
Accept
</button>
<button
type="button"
onClick={() => void onReject(row.reviewKind, row.suggestionId)}
disabled={busyKey !== null}
>
Reject
</button>
<input
value={edits[row.suggestionId] ?? row.suggestedRewrite}
onChange={(event) =>
setEdits((current) => ({
...current,
[row.suggestionId]: event.target.value,
}))
}
/>
<button
type="button"
onClick={() => void onEdit(row.reviewKind, row.suggestionId)}
disabled={busyKey !== null}
>
Edit
</button>
</div>
</td>
</tr>
))}
{issueRows.length === 0 ? (
<tr>
<td colSpan={7}>No review issues yet.</td>
</tr>
) : null}
</tbody>
</table>
</section>
);
}
@@ -6,6 +6,8 @@ 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";
@@ -35,7 +37,13 @@ export default async function ArticleDetailPage({
<RoleNavigation role="EDITOR" />
<section className="panel">
<ArticleDetailShell summary={summary} />
<DraftEditorPanel articleId={articleId} initialDrafts={draftsResponse.drafts ?? []} />
<MediaLibraryPanel assets={detail.assets ?? []} />
<DraftEditorPanel
articleId={articleId}
initialDrafts={draftsResponse.drafts ?? []}
assets={detail.assets ?? []}
/>
<ArticleReviewPanel articleId={articleId} />
</section>
</main>
);
+163
View File
@@ -1,4 +1,9 @@
import type {
AssetGenerateSpecsResponse,
AssetListResponse,
AssetResponse,
AssetUpdateRequest,
AssetUploadResponse,
ArticleCreateRequest,
ArticleCreateResponse,
ArticleDetailResponse,
@@ -18,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,
@@ -459,3 +471,154 @@ export function updateDraft(
request,
);
}
export function generateAssetSpecs(articleId: string): Promise<AssetGenerateSpecsResponse> {
return apiPost<unknown, AssetGenerateSpecsResponse>(
`/api/articles/${articleId}/assets/generate-specs`,
undefined,
);
}
export function fetchAssets(articleId: string): Promise<AssetListResponse> {
return apiGet<AssetListResponse>(`/api/articles/${articleId}/assets`);
}
export function updateAsset(
articleId: string,
assetId: string,
request: AssetUpdateRequest,
): Promise<AssetResponse> {
return apiPatch<AssetUpdateRequest, AssetResponse>(
`/api/articles/${articleId}/assets/${assetId}`,
request,
);
}
export function approveAsset(articleId: string, assetId: string): Promise<AssetResponse> {
return apiPost<unknown, AssetResponse>(
`/api/articles/${articleId}/assets/${assetId}/approve`,
undefined,
);
}
export function uploadAssetFile(
articleId: string,
assetId: string,
file: File,
): Promise<AssetUploadResponse> {
return file.arrayBuffer().then((buffer) => {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index]);
}
if (typeof btoa !== "function") {
throw new Error("Base64 encoding is not available in this runtime");
}
const content_base64 = btoa(binary);
return requestJson<AssetUploadResponse>(
`/api/articles/${articleId}/assets/${assetId}/upload`,
{
method: "POST",
body: JSON.stringify({
filename: file.name || "asset.bin",
content_base64,
content_type: file.type || "application/octet-stream",
}),
},
DEMO_EDITOR_EMAIL,
);
});
}
export function runSeoReview(articleId: string): Promise<SeoReviewRunResponse> {
return apiPost<unknown, SeoReviewRunResponse>(
`/api/articles/${articleId}/seo/review`,
undefined,
);
}
export function fetchSeoReport(articleId: string): Promise<SeoReviewReportResponse> {
return apiGet<SeoReviewReportResponse>(`/api/articles/${articleId}/seo/report`);
}
export function runLanguageReview(articleId: string): Promise<LanguageReviewRunResponse> {
return apiPost<unknown, LanguageReviewRunResponse>(
`/api/articles/${articleId}/language/review`,
undefined,
);
}
export function fetchLanguageReport(
articleId: string,
): Promise<LanguageReviewReportResponse> {
return apiGet<LanguageReviewReportResponse>(`/api/articles/${articleId}/language/report`);
}
export function acceptSeoSuggestion(
articleId: string,
suggestionId: string,
): Promise<ReviewSuggestionActionResponse> {
return apiPost<unknown, ReviewSuggestionActionResponse>(
`/api/articles/${articleId}/seo/suggestions/${suggestionId}/accept`,
undefined,
);
}
export function rejectSeoSuggestion(
articleId: string,
suggestionId: string,
): Promise<ReviewSuggestionActionResponse> {
return apiPost<unknown, ReviewSuggestionActionResponse>(
`/api/articles/${articleId}/seo/suggestions/${suggestionId}/reject`,
undefined,
);
}
export function editSeoSuggestion(
articleId: string,
suggestionId: string,
request: ReviewSuggestionEditRequest,
): Promise<ReviewSuggestionActionResponse> {
return apiPost<ReviewSuggestionEditRequest, ReviewSuggestionActionResponse>(
`/api/articles/${articleId}/seo/suggestions/${suggestionId}/edit`,
request,
);
}
export function acceptLanguageSuggestion(
articleId: string,
suggestionId: string,
): Promise<ReviewSuggestionActionResponse> {
return apiPost<unknown, ReviewSuggestionActionResponse>(
`/api/articles/${articleId}/language/suggestions/${suggestionId}/accept`,
undefined,
);
}
export function rejectLanguageSuggestion(
articleId: string,
suggestionId: string,
): Promise<ReviewSuggestionActionResponse> {
return apiPost<unknown, ReviewSuggestionActionResponse>(
`/api/articles/${articleId}/language/suggestions/${suggestionId}/reject`,
undefined,
);
}
export function editLanguageSuggestion(
articleId: string,
suggestionId: string,
request: ReviewSuggestionEditRequest,
): Promise<ReviewSuggestionActionResponse> {
return apiPost<ReviewSuggestionEditRequest, ReviewSuggestionActionResponse>(
`/api/articles/${articleId}/language/suggestions/${suggestionId}/edit`,
request,
);
}
export function fetchFinalReviewIssues(
articleId: string,
): Promise<FinalReviewIssuesResponse> {
return apiGet<FinalReviewIssuesResponse>(`/api/articles/${articleId}/final-review/issues`);
}
@@ -0,0 +1,70 @@
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-assets/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 { buildMediaLibraryRows, resolveDraftPreviewMarkdown } = moduleExports;
const assets = [
{
id: "asset-approved",
article_id: "article-1",
section_id: "section-1",
asset_type: "hero_image",
title: "Hero visual",
prompt: "hero prompt",
object_key: "articles/article-1/assets/asset-approved/r2/hero.png",
file_url: "file:///tmp/hero.png",
alt_text: "Approved hero visual",
caption: "Used for section one",
status: "APPROVED",
history: [],
created_at: "2026-05-21T00:00:00Z",
updated_at: "2026-05-21T00:00:00Z",
},
{
id: "asset-rejected",
article_id: "article-1",
section_id: "section-1",
asset_type: "diagram",
title: "Rejected visual",
prompt: "diagram prompt",
object_key: "articles/article-1/assets/asset-rejected/r1/rejected.png",
file_url: "file:///tmp/rejected.png",
alt_text: "Rejected visual",
caption: "Do not publish",
status: "REJECTED",
history: [],
created_at: "2026-05-21T00:00:01Z",
updated_at: "2026-05-21T00:00:01Z",
},
];
const rows = buildMediaLibraryRows(assets);
assert.equal(rows.length, 2);
assert.equal(rows[0].title, "Hero visual");
assert.equal(rows[0].publishReady, true);
assert.equal(rows[1].publishReady, false);
const markdown = [
"## Intro",
"",
"![Visual placeholder: Intro](placeholder://section-1/1)",
"![Visual placeholder: Intro 2](placeholder://section-1/2)",
].join("\n");
const resolved = resolveDraftPreviewMarkdown(markdown, assets);
assert.ok(resolved.includes("file:///tmp/hero.png"));
assert.ok(resolved.includes("placeholder://section-1/2"));
assert.ok(!resolved.includes("file:///tmp/rejected.png"));
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const testDir = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(testDir, "../src/features/article-review/model.ts");
const source = readFileSync(sourcePath, "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
});
const moduleExports = {};
new Function("exports", compiled.outputText)(moduleExports);
const { buildReviewIssueRows, buildFinalReviewSummary } = moduleExports;
const seoReport = {
id: "11111111-1111-1111-1111-111111111111",
article_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
review_kind: "SEO",
draft_id: "dddddddd-dddd-dddd-dddd-dddddddddddd",
score: 72,
issues: [
{
id: "meta-description-length",
suggestion_id: "22222222-2222-2222-2222-222222222222",
severity: "HIGH",
location: "meta_description",
message: "Meta description too short.",
suggested_fix: "Use 120-160 chars.",
suggested_rewrite: "Expanded meta description text.",
status: "PENDING",
},
],
recommended_slug: "seo-language-review-guide",
recommended_title: "SEO Language Review Guide",
schema_json: { "@type": "Article" },
rules_snapshot: { meta_description_min: 120 },
created_at: "2026-05-22T12:00:00Z",
};
const languageReport = {
...seoReport,
id: "33333333-3333-3333-3333-333333333333",
review_kind: "LANGUAGE",
issues: [
{
id: "long-sentence",
suggestion_id: "44444444-4444-4444-4444-444444444444",
severity: "MEDIUM",
location: "body_markdown",
message: "Sentence exceeds 24 words.",
suggested_fix: "Split sentence.",
suggested_rewrite: "This sentence is shorter.",
status: "ACCEPTED",
},
],
};
const rows = buildReviewIssueRows(seoReport, languageReport);
assert.equal(rows.length, 2);
assert.equal(rows[0].reviewKind, "SEO");
assert.equal(rows[0].suggestionId, "22222222-2222-2222-2222-222222222222");
assert.equal(rows[1].reviewKind, "LANGUAGE");
assert.equal(rows[1].status, "ACCEPTED");
const summary = buildFinalReviewSummary({
article_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
unresolved_count: 2,
unresolved_issues: [
{
review_kind: "SEO",
suggestion_id: "22222222-2222-2222-2222-222222222222",
severity: "HIGH",
location: "meta_description",
message: "Meta description too short.",
status: "PENDING",
},
{
review_kind: "LANGUAGE",
suggestion_id: "55555555-5555-5555-5555-555555555555",
severity: "MEDIUM",
location: "body_markdown",
message: "Sentence exceeds 24 words.",
status: "PENDING",
},
],
});
assert.equal(summary.unresolvedCount, 2);
assert.equal(summary.unresolvedTitles.length, 2);
assert.ok(summary.unresolvedTitles[0].startsWith("SEO:"));
File diff suppressed because it is too large Load Diff
+135 -1
View File
@@ -102,6 +102,29 @@ export type ArticleSummary = {
export type ArticleWorkflowStatus = "ARTICLE_BRIEF_CREATED" | "BOUNDARY_QUESTIONS_GENERATED" | "BOUNDARY_ANSWERS_SUBMITTED" | "PLAN_GENERATED" | "PLAN_REVIEW_REQUIRED" | "PLAN_REVISION_REQUIRED" | "RESEARCH_RUNNING" | "EVIDENCE_MATRIX_READY" | "PARALLEL_PRODUCTION_RUNNING" | "DRAFT_ASSEMBLED" | "SEO_AND_LANGUAGE_REVIEW_READY" | "FINAL_REVIEW_REQUIRED" | "FINAL_REVISION_REQUIRED" | "PUBLISH_DRY_RUN_REQUIRED" | "PUBLISH_COMMIT_READY" | "PUBLISH_COMMIT_CREATED";
export type AssetGenerateSpecsResponse = {
assets?: AssetSummary[];
};
export type AssetListResponse = {
assets?: AssetSummary[];
};
export type AssetResponse = {
asset: AssetSummary;
};
export type AssetRevisionSummary = {
action: string;
actor_user_id?: string | null;
article_id: string;
asset_id: string;
created_at: string;
id: string;
payload?: Record<string, unknown>;
revision_index: number;
};
export type AssetStatus = "PENDING" | "GENERATED" | "APPROVED" | "REJECTED";
export type AssetSummary = {
@@ -109,14 +132,38 @@ export type AssetSummary = {
article_id: string;
asset_type: AssetType;
caption?: string | null;
created_at: string;
file_url?: string | null;
history?: AssetRevisionSummary[];
id: string;
object_key?: string | null;
prompt?: string | null;
section_id?: string | null;
status: AssetStatus;
title: string;
updated_at: string;
};
export type AssetType = "hero_image" | "diagram" | "table" | "inline_image";
export type AssetType = "hero_image" | "diagram" | "table" | "flowchart" | "comparison_matrix" | "architecture_diagram" | "inline_image";
export type AssetUpdateRequest = {
alt_text?: string | null;
caption?: string | null;
prompt?: string | null;
section_id?: string | null;
status?: AssetStatus | null;
title?: string | null;
};
export type AssetUploadRequest = {
content_base64: string;
content_type?: string | null;
filename: string;
};
export type AssetUploadResponse = {
asset: AssetSummary;
};
export type BoundaryQuestionListResponse = {
job?: AgentJobSummary | null;
@@ -157,6 +204,51 @@ export type ClaimSummary = {
export type ClaimSupportStatus = "SUPPORTED" | "UNSUPPORTED" | "NEEDS_REVIEW";
export type ContentReviewIssueSummary = {
id: string;
location: string;
message: string;
severity: string;
status: ReviewSuggestionStatus;
suggested_fix?: string | null;
suggested_rewrite?: string | null;
suggestion_id: string;
};
export type ContentReviewKind = "SEO" | "LANGUAGE";
export type ContentReviewReportSummary = {
article_id: string;
created_at: string;
draft_id: string;
id: string;
issues?: ContentReviewIssueSummary[];
recommended_slug: string;
recommended_title: string;
review_kind: ContentReviewKind;
rules_snapshot?: Record<string, unknown>;
schema_json?: Record<string, unknown>;
score: number;
};
export type ContentReviewSuggestionSummary = {
applied_text?: string | null;
article_id: string;
created_at: string;
id: string;
location: string;
message: string;
patch?: Record<string, unknown>;
report_id: string;
review_kind: ContentReviewKind;
severity: string;
status: ReviewSuggestionStatus;
suggested_fix?: string | null;
suggested_rewrite?: string | null;
suggestion_key: string;
updated_at: string;
};
export type CurrentUser = {
display_name: string;
email: string;
@@ -257,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[];
};
@@ -397,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;
@@ -449,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;
+44 -14
View File
@@ -41,14 +41,14 @@ Development description: Implement asset specifications, generated or uploaded a
## Acceptance Criteria
- [ ] TDD pre-requirement: before implementation, write one failing public API test for approving an asset and seeing it become available to the article; proceed one asset behavior at a time and record evidence in `Result`.
- [ ] Asset specs are generated and linked to article or section.
- [ ] File uploads store objects in object storage and persist object keys.
- [ ] Asset approval writes workflow event.
- [ ] Rejected assets are excluded from publish bundle.
- [ ] Replacement preserves audit history.
- [ ] Media library lists article assets with status, type, title, preview/file link, alt text, and caption.
- [ ] Draft preview uses approved asset references where available.
- [x] TDD pre-requirement: before implementation, write one failing public API test for approving an asset and seeing it become available to the article; proceed one asset behavior at a time and record evidence in `Result`.
- [x] Asset specs are generated and linked to article or section.
- [x] File uploads store objects in object storage and persist object keys.
- [x] Asset approval writes workflow event.
- [x] Rejected assets are excluded from publish bundle.
- [x] Replacement preserves audit history.
- [x] Media library lists article assets with status, type, title, preview/file link, alt text, and caption.
- [x] Draft preview uses approved asset references where available.
## Verification
@@ -59,9 +59,39 @@ Development description: Implement asset specifications, generated or uploaded a
## 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: Implementation completed, acceptance criteria closed.
- TDD plan:
1. Keep RED pre-requirement test that calls approve API and asserts asset visibility in article context.
2. Expand integration tests to cover full assets lifecycle: specs generation, upload, approve/reject, replacement audit, and draft-facing inclusion/exclusion behavior.
3. Implement API contracts + persistence + routes + object storage integration.
4. Add frontend media library model/UI and draft preview asset reference resolution.
- Red evidence:
- Initial pre-requirement failure: `POST /api/articles/{article_id}/assets/{asset_id}/approve` returned `404 Not Found`.
- During implementation hardening: transient runtime bug `NameError: name 'approved' is not defined` in `approve_asset` (fixed), confirmed by rerun to green.
- Green evidence:
- Backend assets suite now green with 7 integration tests, including:
- specs linked to sections,
- upload persists `object_key` and writes object file,
- approval writes workflow event,
- rejected asset excluded from assembled draft publish-facing data,
- replacement keeps full audit history.
- Draft assembly and parallel production suites remain green after assets integration.
- Frontend media-library model tests and typecheck are green.
- Refactor notes:
- Added dedicated assets application module and routes.
- Extended object storage adapter with `put_bytes`.
- Added persistent `asset_revisions` history table and API-visible history payload.
- Updated shared OpenAPI contracts and generated TS types.
- Verification output:
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_assets_media_library_public_api.py`
- `Ran 7 tests ... OK`
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_draft_assembly_public_api.py`
- `Ran 5 tests ... OK`
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_parallel_production_public_api.py`
- `Ran 6 tests ... OK`
- `node apps/frontend/tests/article_assets.model.test.mjs`
- `OK` (exit code `0`)
- `node apps/frontend/tests/draft_editor.model.test.mjs`
- `OK` (exit code `0`)
- `pnpm --dir apps/frontend typecheck`
- `tsc --noEmit` completed successfully.
+45 -14
View File
@@ -46,14 +46,14 @@ Development description: Implement SEO and linguistic review jobs, issue reports
## Acceptance Criteria
- [ ] TDD pre-requirement: before implementation, write one failing behavior test that runs an SEO review and returns a visible issue through the public API; proceed one review behavior at a time and record evidence in `Result`.
- [ ] SEO report includes score, issues, suggested fixes, recommended slug/title, and schema JSON.
- [ ] Language report includes severity, location, message, and suggested rewrite.
- [ ] Editor can accept, reject, or edit each suggestion.
- [ ] Accepted content changes create a new immutable draft version.
- [ ] Target-site SEO and tone rules override global defaults.
- [ ] Final review can show unresolved SEO/language issues.
- [ ] Fake review runner produces deterministic demo reports.
- [x] TDD pre-requirement: before implementation, write one failing behavior test that runs an SEO review and returns a visible issue through the public API; proceed one review behavior at a time and record evidence in `Result`.
- [x] SEO report includes score, issues, suggested fixes, recommended slug/title, and schema JSON.
- [x] Language report includes severity, location, message, and suggested rewrite.
- [x] Editor can accept, reject, or edit each suggestion.
- [x] Accepted content changes create a new immutable draft version.
- [x] Target-site SEO and tone rules override global defaults.
- [x] Final review can show unresolved SEO/language issues.
- [x] Fake review runner produces deterministic demo reports.
## Verification
@@ -64,9 +64,40 @@ Development description: Implement SEO and linguistic review jobs, issue reports
## Result
- Status: Pending execution.
- TDD plan: To be filled during execution.
- Red evidence: To be filled during execution.
- Green evidence: To be filled during execution.
- Refactor notes: To be filled during execution.
- Verification output: To be filled during execution.
- Status: Completed (pre-requirement RED + implementation GREEN).
- TDD plan (done):
1. Prepare article through public flow to assembled draft state.
2. Run SEO and language review via public endpoints.
3. Fetch reports and verify issue visibility through API.
4. Apply suggestion actions (accept/reject/edit) and verify immutable draft versioning.
5. Verify unresolved issues in final review context and deterministic fake-runner behavior.
- Red evidence:
- Pre-requirement test initially failed with `404 Not Found` on `POST /api/articles/{article_id}/seo/review`.
- Green evidence:
- Added review domain contracts, persistence tables, repository, application service, and routes:
- `POST /api/articles/{article_id}/seo/review`
- `GET /api/articles/{article_id}/seo/report`
- `POST /api/articles/{article_id}/language/review`
- `GET /api/articles/{article_id}/language/report`
- suggestion actions:
- `POST /api/articles/{article_id}/seo/suggestions/{suggestion_id}/accept|reject|edit`
- `POST /api/articles/{article_id}/language/suggestions/{suggestion_id}/accept|reject|edit`
- unresolved issues endpoint:
- `GET /api/articles/{article_id}/final-review/issues`
- Reports are generated deterministically from assembled draft + site rules and stored in review tables.
- Accept/edit actions that modify content/metadata create a new immutable draft version through existing draft versioning flow.
- Site-level overrides are applied from `target_sites.seo_rules` (`seo_review` / `language_review`) over defaults.
- Added frontend review feature model + UI and API bindings.
- Regenerated shared contracts:
- `packages/shared/openapi.json`
- `packages/shared/src/api-types.ts`
- Refactor notes:
- `ContentReviewReportSummary.schema_json` currently emits a pydantic shadow warning; behavior is correct and contracts remain stable.
- Verification output:
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_seo_language_review_public_api.py` -> `OK (6 tests)`
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_draft_assembly_public_api.py` -> `OK (5 tests)`
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_assets_media_library_public_api.py` -> `OK (7 tests)`
- `node apps/frontend/tests/draft_editor.model.test.mjs` -> `OK`
- `node apps/frontend/tests/article_assets.model.test.mjs` -> `OK`
- `node apps/frontend/tests/article_review.model.test.mjs` -> `OK`
- `pnpm --dir apps/frontend typecheck` -> `OK`