feat: implement final approval gate workflow and checklist

This commit is contained in:
2026-05-22 00:31:48 +03:00
parent 6dcbe0e36c
commit d3990b4019
15 changed files with 1851 additions and 14 deletions
@@ -0,0 +1,378 @@
from __future__ import annotations
import re
from datetime import UTC, datetime
from uuid import UUID
from src.domain.contracts import (
AgentJobType,
AssetStatus,
AssetType,
ArticleWorkflowStatus,
ClaimRiskLevel,
ClaimSupportStatus,
ContentReviewKind,
FinalApprovalChecklistItemSummary,
FinalApprovalChecklistResponse,
FinalApprovalRequest,
FinalApprovalResponse,
FinalRevisionRequest,
FinalRevisionRequestResponse,
PlanReviewStatus,
)
def get_final_approval_checklist(
repository: object,
*,
article_id: UUID,
request: FinalApprovalRequest | None = None,
) -> FinalApprovalChecklistResponse:
repository.articles.get(article_id)
latest_draft = repository.article_drafts.latest_for_article(article_id)
approved_plan = _approved_plan(repository, article_id=article_id)
evidence = repository.evidence_items.list_for_article(article_id)
assets = repository.assets.list_for_article(article_id)
unresolved = repository.content_reviews.list_unresolved_for_article(article_id=article_id)
high_risk_unsupported = _high_risk_unsupported_claims(
repository,
article_id=article_id,
draft=latest_draft,
)
settings = request.publishing_settings if request is not None else None
requested_draft_version = request.draft_version if request is not None else None
checklist_items: list[FinalApprovalChecklistItemSummary] = []
blockers: list[str] = []
completed_items: list[str] = []
def add_item(key: str, title: str, completed: bool, blocker: str | None = None) -> None:
checklist_items.append(
FinalApprovalChecklistItemSummary(
key=key,
title=title,
completed=completed,
blocker=blocker,
)
)
if completed:
completed_items.append(title)
elif blocker:
blockers.append(blocker)
has_draft = latest_draft is not None
add_item(
key="exact_draft_version",
title="Exact draft version selected",
completed=has_draft and requested_draft_version == latest_draft.version,
blocker=(
None
if has_draft and requested_draft_version == latest_draft.version
else (
"Draft version is required for final approval."
if not has_draft
else f"Final approval requires latest draft version {latest_draft.version}."
)
),
)
add_item(
key="plan_followed",
title="Plan followed",
completed=has_draft and _draft_contains_approved_plan(approved_plan, latest_draft.body_markdown),
blocker="Approved plan sections are not fully reflected in the assembled draft.",
)
add_item(
key="evidence_reviewed",
title="Evidence reviewed",
completed=bool(evidence) and all(item.review_status != "PENDING" for item in evidence),
blocker="Evidence review is incomplete (there are pending evidence items).",
)
add_item(
key="no_high_risk_unsupported_claims",
title="No high-risk unsupported claims remain",
completed=not high_risk_unsupported,
blocker=(
None
if not high_risk_unsupported
else (
"High-risk unsupported claims must be resolved: "
+ "; ".join(high_risk_unsupported[:3])
)
),
)
add_item(
key="seo_metadata_approved",
title="SEO metadata approved",
completed=has_draft and _has_seo_metadata(latest_draft) and not _has_unresolved_meta_issues(unresolved),
blocker="SEO metadata is incomplete or has unresolved review issues.",
)
required_assets = [asset for asset in assets if asset.status != AssetStatus.REJECTED]
unapproved_required_assets = [
asset
for asset in required_assets
if asset.status != AssetStatus.APPROVED or not asset.file_url
]
add_item(
key="images_assets_approved",
title="Images/assets approved",
completed=bool(required_assets) and not unapproved_required_assets,
blocker=(
"Required assets are missing."
if not required_assets
else f"{len(unapproved_required_assets)} required assets are not approved."
),
)
required_table_diagrams = [
asset
for asset in required_assets
if asset.asset_type in {
AssetType.DIAGRAM,
AssetType.TABLE,
AssetType.FLOWCHART,
AssetType.COMPARISON_MATRIX,
AssetType.ARCHITECTURE_DIAGRAM,
}
]
unapproved_table_diagrams = [
asset
for asset in required_table_diagrams
if asset.status != AssetStatus.APPROVED or not asset.file_url
]
add_item(
key="tables_and_diagrams_approved",
title="Tables and diagrams approved",
completed=bool(required_table_diagrams) and not unapproved_table_diagrams,
blocker=(
"Required table/diagram assets are missing."
if not required_table_diagrams
else f"{len(unapproved_table_diagrams)} table/diagram assets are not approved."
),
)
has_internal_links = has_draft and _has_internal_links(latest_draft.body_markdown)
add_item(
key="internal_links_approved",
title="Internal links approved",
completed=bool(has_internal_links) and not _has_unresolved_internal_link_issue(unresolved),
blocker="Draft must include approved internal links.",
)
frontmatter = settings.frontmatter if settings is not None else None
add_item(
key="frontmatter_fields_selected",
title="Frontmatter fields selected",
completed=isinstance(frontmatter, dict) and bool(frontmatter),
blocker="Required publishing settings are missing: frontmatter fields.",
)
content_path = settings.content_path if settings is not None else None
add_item(
key="content_path_selected",
title="Content path selected",
completed=bool(content_path and content_path.strip()),
blocker="Required publishing settings are missing: content path.",
)
author = settings.author if settings is not None else None
add_item(
key="author_selected",
title="Author selected",
completed=bool(author and author.strip()),
blocker="Required publishing settings are missing: author.",
)
publishing_mode = settings.publishing_mode if settings is not None else None
add_item(
key="publishing_mode_selected",
title="Publishing mode selected",
completed=bool(publishing_mode and publishing_mode.strip()),
blocker="Required publishing settings are missing: publishing mode.",
)
can_approve = len(blockers) == 0
return FinalApprovalChecklistResponse(
article_id=article_id,
draft_version=latest_draft.version if latest_draft is not None else None,
requested_draft_version=requested_draft_version,
can_approve=can_approve,
completed_items=completed_items,
blockers=blockers,
items=checklist_items,
)
def approve_final_article(
repository: object,
*,
article_id: UUID,
request: FinalApprovalRequest,
actor_user_id: UUID,
) -> FinalApprovalResponse:
article = repository.articles.get(article_id)
checklist = get_final_approval_checklist(
repository,
article_id=article_id,
request=request,
)
if not checklist.can_approve:
raise PermissionError("; ".join(checklist.blockers))
now = _now()
updated = repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="FINAL_APPROVAL_GRANTED",
from_status=article.status,
to_status=ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
actor_user_id=actor_user_id,
payload={
"draft_version": checklist.draft_version,
"publishing_settings": (
request.publishing_settings.model_dump(mode="json")
if request.publishing_settings is not None
else {}
),
"completed_items": checklist.completed_items,
},
created_at=now,
)
return FinalApprovalResponse(article=updated, checklist=checklist)
def request_final_revision(
repository: object,
*,
article_id: UUID,
request: FinalRevisionRequest,
actor_user_id: UUID,
) -> FinalRevisionRequestResponse:
article = repository.articles.get(article_id)
checklist_request = FinalApprovalRequest(
draft_version=request.draft_version,
publishing_settings=None,
)
checklist = get_final_approval_checklist(
repository,
article_id=article_id,
request=checklist_request,
)
now = _now()
updated = repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="FINAL_REVISION_REQUESTED",
from_status=article.status,
to_status=ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
actor_user_id=actor_user_id,
payload={
"reason": (request.reason or "").strip(),
"draft_version": checklist.draft_version,
"blockers": checklist.blockers,
"completed_items": checklist.completed_items,
},
created_at=now,
)
return FinalRevisionRequestResponse(article=updated, checklist=checklist)
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 _draft_contains_approved_plan(approved_plan: object | None, body_markdown: str) -> bool:
if approved_plan is None or not approved_plan.sections:
return False
for section in approved_plan.sections:
if f"## {section.heading}" not in body_markdown:
return False
return True
def _has_seo_metadata(draft: object) -> bool:
return bool(draft.meta_title and draft.meta_title.strip()) and bool(
draft.meta_description and draft.meta_description.strip()
)
def _has_unresolved_meta_issues(unresolved: list[object]) -> bool:
return any(
item.review_kind == ContentReviewKind.SEO
and item.location in {"meta_title", "meta_description", "title"}
for item in unresolved
)
def _has_internal_links(markdown: str) -> bool:
return re.search(r"\]\(/[^)]+\)", markdown) is not None
def _has_unresolved_internal_link_issue(unresolved: list[object]) -> bool:
for item in unresolved:
if item.suggestion_key == "internal-link":
return True
if "internal link" in item.message.lower():
return True
return False
def _high_risk_unsupported_claims(
repository: object,
*,
article_id: UUID,
draft: object | None,
) -> list[str]:
claims: list[str] = []
for claim in repository.claims.list_for_article(article_id):
if (
claim.support_status == ClaimSupportStatus.UNSUPPORTED
and claim.risk_level == ClaimRiskLevel.HIGH
):
claims.append(claim.claim_text)
for job in repository.agent_jobs.list_for_article(article_id):
if job.job_type != AgentJobType.SECTION_SCAFFOLD:
continue
raw_claims = job.payload.get("unsupported_claims")
if not isinstance(raw_claims, list):
continue
for raw in raw_claims:
if isinstance(raw, dict):
risk_level = str(raw.get("risk_level", "")).lower()
claim_text = str(raw.get("claim_text", "")).strip()
if risk_level == "high" and claim_text:
claims.append(claim_text)
if draft is not None:
for warning in draft.unsupported_claim_warnings:
if "high-risk" in warning.lower():
claims.append(warning)
deduped: list[str] = []
seen: set[str] = set()
for claim in claims:
normalized = claim.strip()
if not normalized:
continue
if normalized in seen:
continue
seen.add(normalized)
deduped.append(normalized)
return deduped
def _now() -> datetime:
return datetime.now(UTC)
@@ -57,6 +57,11 @@ from .models import (
EvidenceMatrixResponse,
EvidenceResponse,
EvidenceUpdateRequest,
FinalApprovalChecklistItemSummary,
FinalApprovalChecklistResponse,
FinalApprovalPublishingSettings,
FinalApprovalRequest,
FinalApprovalResponse,
PlanSectionSummary,
PlanSectionEditRequest,
PlanListResponse,
@@ -78,6 +83,8 @@ from .models import (
SeoReviewReportResponse,
LanguageReviewRunResponse,
LanguageReviewReportResponse,
FinalRevisionRequest,
FinalRevisionRequestResponse,
FinalReviewIssueSummary,
FinalReviewIssuesResponse,
RunnerFileRef,
@@ -149,6 +156,13 @@ __all__ = [
"EvidenceMatrixResponse",
"EvidenceResponse",
"EvidenceUpdateRequest",
"FinalApprovalChecklistItemSummary",
"FinalApprovalChecklistResponse",
"FinalApprovalPublishingSettings",
"FinalApprovalRequest",
"FinalApprovalResponse",
"FinalRevisionRequest",
"FinalRevisionRequestResponse",
"FinalReviewIssueSummary",
"FinalReviewIssuesResponse",
"LanguageReviewRunResponse",
@@ -598,6 +598,50 @@ class FinalReviewIssuesResponse(ContractModel):
unresolved_issues: list[FinalReviewIssueSummary] = Field(default_factory=list)
class FinalApprovalPublishingSettings(ContractModel):
content_path: str | None = None
author: str | None = None
publishing_mode: str | None = None
frontmatter: JsonObject | None = None
class FinalApprovalRequest(ContractModel):
draft_version: int | None = Field(default=None, ge=1)
publishing_settings: FinalApprovalPublishingSettings | None = None
class FinalRevisionRequest(ContractModel):
reason: str | None = None
draft_version: int | None = Field(default=None, ge=1)
class FinalApprovalChecklistItemSummary(ContractModel):
key: str = Field(min_length=1)
title: str = Field(min_length=1)
completed: bool
blocker: str | None = None
class FinalApprovalChecklistResponse(ContractModel):
article_id: UUID
draft_version: int | None = Field(default=None, ge=1)
requested_draft_version: int | None = Field(default=None, ge=1)
can_approve: bool
completed_items: list[str] = Field(default_factory=list)
blockers: list[str] = Field(default_factory=list)
items: list[FinalApprovalChecklistItemSummary] = Field(default_factory=list)
class FinalApprovalResponse(ContractModel):
article: ArticleSummary
checklist: FinalApprovalChecklistResponse
class FinalRevisionRequestResponse(ContractModel):
article: ArticleSummary
checklist: FinalApprovalChecklistResponse
class AssetGenerateSpecsResponse(ContractModel):
assets: list[AssetSummary] = Field(default_factory=list)
@@ -58,6 +58,11 @@ from .models import (
ContentReviewIssueSummary,
ContentReviewReportSummary,
ContentReviewSuggestionSummary,
FinalApprovalChecklistItemSummary,
FinalApprovalChecklistResponse,
FinalApprovalPublishingSettings,
FinalApprovalRequest,
FinalApprovalResponse,
EvidenceSummary,
EvidenceCreateRequest,
EvidenceMatrixResponse,
@@ -84,6 +89,8 @@ from .models import (
ReviewSuggestionEditRequest,
ReviewActionResponse,
ReviewSummary,
FinalRevisionRequest,
FinalRevisionRequestResponse,
SeoReviewReportResponse,
SeoReviewRunResponse,
RunnerFileRef,
@@ -167,6 +174,13 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
ContentReviewIssueSummary,
ContentReviewReportSummary,
ContentReviewSuggestionSummary,
FinalApprovalChecklistItemSummary,
FinalApprovalChecklistResponse,
FinalApprovalPublishingSettings,
FinalApprovalRequest,
FinalApprovalResponse,
FinalRevisionRequest,
FinalRevisionRequestResponse,
FinalReviewIssueSummary,
FinalReviewIssuesResponse,
LanguageReviewReportResponse,
+2
View File
@@ -15,6 +15,7 @@ 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.final_approval import router as final_approval_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
@@ -29,6 +30,7 @@ app.include_router(plans_router)
app.include_router(evidence_router)
app.include_router(drafts_router)
app.include_router(reviews_router)
app.include_router(final_approval_router)
app.include_router(agent_jobs_router)
app.include_router(internal_agent_jobs_router)
app.include_router(sites_router)
@@ -0,0 +1,89 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from src.application.final_approval import (
approve_final_article,
get_final_approval_checklist,
request_final_revision,
)
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
CurrentUser,
FinalApprovalChecklistResponse,
FinalApprovalRequest,
FinalApprovalResponse,
FinalRevisionRequest,
FinalRevisionRequestResponse,
)
from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles
router = APIRouter(prefix="/api", tags=["final-approval"])
@router.get(
"/articles/{article_id}/final-approval/checklist",
response_model=FinalApprovalChecklistResponse,
)
def get_article_final_approval_checklist(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> FinalApprovalChecklistResponse:
try:
return get_final_approval_checklist(
repository,
article_id=article_id,
request=None,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/final-approval",
response_model=FinalApprovalResponse,
)
def post_article_final_approval(
article_id: UUID,
request: FinalApprovalRequest,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> FinalApprovalResponse:
try:
return approve_final_article(
repository,
article_id=article_id,
request=request,
actor_user_id=current_user.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.post(
"/articles/{article_id}/final-revision-request",
response_model=FinalRevisionRequestResponse,
)
def post_article_final_revision_request(
article_id: UUID,
request: FinalRevisionRequest,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> FinalRevisionRequestResponse:
try:
return request_final_revision(
repository,
article_id=article_id,
request=request,
actor_user_id=current_user.id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@@ -0,0 +1,427 @@
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_ADMIN_EMAIL = "admin@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class FinalApprovalGatePublicApiTest(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) / 'final-approval-gate.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_high_risk_unsupported_claims_block_final_approval(self) -> None:
article_id, draft = self._prepare_article_ready_for_final_approval(
include_high_risk_unsupported=True,
)
response = self.client.post(
f"/api/articles/{article_id}/final-approval",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json=self._final_approval_payload(draft_version=draft["version"]),
)
self.assertEqual(409, response.status_code, response.text)
self.assertIn("high-risk unsupported claims", response.text.lower())
def test_final_approval_requires_exact_draft_version(self) -> None:
article_id, draft = self._prepare_article_ready_for_final_approval()
response = self.client.post(
f"/api/articles/{article_id}/final-approval",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json=self._final_approval_payload(draft_version=draft["version"] + 1),
)
self.assertEqual(409, response.status_code, response.text)
self.assertIn("latest draft version", response.text.lower())
def test_missing_required_publishing_settings_block_final_approval(self) -> None:
article_id, draft = self._prepare_article_ready_for_final_approval()
response = self.client.post(
f"/api/articles/{article_id}/final-approval",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"draft_version": draft["version"],
"publishing_settings": {
"content_path": "",
"author": "",
"publishing_mode": "",
"frontmatter": {},
},
},
)
self.assertEqual(409, response.status_code, response.text)
self.assertIn("required publishing settings are missing", response.text.lower())
def test_unapproved_required_assets_block_final_approval(self) -> None:
article_id, draft = self._prepare_article_ready_for_final_approval(approve_assets=False)
response = self.client.post(
f"/api/articles/{article_id}/final-approval",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json=self._final_approval_payload(draft_version=draft["version"]),
)
self.assertEqual(409, response.status_code, response.text)
self.assertIn("required assets", response.text.lower())
def test_successful_final_approval_writes_event_and_moves_to_publish_dry_run_required(self) -> None:
article_id, draft = self._prepare_article_ready_for_final_approval()
actor = self.client.get(
"/api/me",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["user"]
payload = self._final_approval_payload(draft_version=draft["version"])
response = self.client.post(
f"/api/articles/{article_id}/final-approval",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json=payload,
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertEqual("PUBLISH_DRY_RUN_REQUIRED", body["article"]["status"])
self.assertTrue(body["checklist"]["can_approve"])
self.assertFalse(body["checklist"]["blockers"])
detail = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()
events = [
event
for event in detail["workflow_events"]
if event["event_type"] == "FINAL_APPROVAL_GRANTED"
]
self.assertTrue(events, "Expected FINAL_APPROVAL_GRANTED event")
latest = events[-1]
self.assertEqual(actor["id"], latest["actor_user_id"])
self.assertTrue(latest["created_at"])
self.assertEqual(draft["version"], latest["payload"]["draft_version"])
self.assertEqual(payload["publishing_settings"], latest["payload"]["publishing_settings"])
def test_revision_request_writes_event_and_moves_article_to_final_revision_required(self) -> None:
article_id, draft = self._prepare_article_ready_for_final_approval()
actor = self.client.get(
"/api/me",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["user"]
response = self.client.post(
f"/api/articles/{article_id}/final-revision-request",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"reason": "Need factual clarification in section 2.",
"draft_version": draft["version"],
},
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertEqual("FINAL_REVISION_REQUIRED", body["article"]["status"])
detail = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()
events = [
event
for event in detail["workflow_events"]
if event["event_type"] == "FINAL_REVISION_REQUESTED"
]
self.assertTrue(events, "Expected FINAL_REVISION_REQUESTED event")
latest = events[-1]
self.assertEqual(actor["id"], latest["actor_user_id"])
self.assertEqual("Need factual clarification in section 2.", latest["payload"]["reason"])
self.assertEqual(draft["version"], latest["payload"]["draft_version"])
def test_checklist_endpoint_returns_blockers_and_completed_items(self) -> None:
article_id, _ = self._prepare_article_ready_for_final_approval(approve_assets=False)
response = self.client.get(
f"/api/articles/{article_id}/final-approval/checklist",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, response.status_code, response.text)
checklist = response.json()
self.assertFalse(checklist["can_approve"])
self.assertTrue(checklist["blockers"])
self.assertTrue(checklist["completed_items"])
self.assertTrue(any(item["completed"] for item in checklist["items"]))
self.assertTrue(any(not item["completed"] for item in checklist["items"]))
def _final_approval_payload(self, *, draft_version: int) -> dict[str, Any]:
return {
"draft_version": draft_version,
"publishing_settings": {
"content_path": "/guides/final-approval-gate",
"author": "Editorial Team",
"publishing_mode": "MANUAL",
"frontmatter": {
"category": "Guides",
"tags": ["workflow", "approval"],
},
},
}
def _prepare_article_ready_for_final_approval(
self,
*,
include_high_risk_unsupported: bool = False,
approve_assets: bool = True,
) -> tuple[str, dict[str, Any]]:
article_id = self._create_article_with_approved_plan()
self._ensure_evidence_ready(article_id)
self._approve_all_evidence(article_id)
section_jobs = self._start_parallel_production(article_id)
self.assertGreaterEqual(len(section_jobs), 1)
first_job = section_jobs[0]
if include_high_risk_unsupported:
failed = self._complete_job(
first_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/high-risk.md"}],
"payload": {
"used_evidence_ids": first_job["payload"]["used_evidence_ids"],
"unsupported_claims": [
{
"claim_text": "Unverified migration benchmark for market leadership.",
"risk_level": "high",
}
],
"draft_markdown": "Section draft with high-risk unsupported claim.",
},
},
)
self.assertEqual("FAILED", failed["status"])
retry_response = self.client.post(
f"/api/agent-jobs/{first_job['id']}/retry",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(201, retry_response.status_code, retry_response.text)
retry_job = retry_response.json()["job"]
retried = self._complete_job(
retry_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/retry-clean.md"}],
"payload": {
"used_evidence_ids": retry_job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": (
f"## {retry_job['payload']['heading']}\n\n"
"Clean retry section with [internal guide](/guides/internal)."
),
},
},
)
self.assertEqual("SUCCEEDED", retried["status"])
else:
completed = self._complete_job(
first_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/section-1.md"}],
"payload": {
"used_evidence_ids": first_job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": (
f"## {first_job['payload']['heading']}\n\n"
"Use [internal guide](/guides/internal) for implementation details."
),
},
},
)
self.assertEqual("SUCCEEDED", completed["status"])
for index, job in enumerate(section_jobs[1:], start=2):
completed = self._complete_job(
job["id"],
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} ready.",
},
},
)
self.assertEqual("SUCCEEDED", completed["status"])
assemble_response = self.client.post(
f"/api/articles/{article_id}/draft/assemble",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, assemble_response.status_code, assemble_response.text)
draft = assemble_response.json()["draft"]
assets_response = self.client.post(
f"/api/articles/{article_id}/assets/generate-specs",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, assets_response.status_code, assets_response.text)
assets = assets_response.json()["assets"]
self.assertTrue(assets)
if approve_assets:
for asset in assets:
upload_response = self.client.post(
f"/api/articles/{article_id}/assets/{asset['id']}/upload",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"filename": f"{asset['id']}.png",
"content_base64": base64.b64encode(b"asset-binary").decode("utf-8"),
"content_type": "image/png",
},
)
self.assertEqual(200, upload_response.status_code, upload_response.text)
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)
return article_id, draft
def _approve_all_evidence(self, article_id: str) -> None:
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)
evidence = evidence_response.json()["evidence"]
self.assertTrue(evidence)
for item in evidence:
patch_response = self.client.patch(
f"/api/articles/{article_id}/evidence/{item['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"review_status": "APPROVED"},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
def _complete_job(self, job_id: str, *, output: dict[str, Any]) -> dict[str, Any]:
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": output,
},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["job"]
def _start_parallel_production(self, article_id: str) -> 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)
return [job for job in response.json()["jobs"] if job["job_type"] == "SECTION_SCAFFOLD"]
def _ensure_evidence_ready(self, article_id: str) -> 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 _create_article_with_approved_plan(self) -> str:
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": "Validate final approval gate checklist and transitions.",
"working_title": "Final Approval Gate",
"content_type": "longform_guide",
"primary_keyword": "final approval gate",
},
).json()["article"]
article_id = 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_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_response.status_code, approve_response.text)
return article_id
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,54 @@
import type {
FinalApprovalChecklistItemSummary,
FinalApprovalChecklistResponse,
} from "@pipeline/shared";
export type FinalApprovalChecklistItemViewModel = {
key: string;
title: string;
completed: boolean;
blocker: string;
};
export type FinalApprovalViewModel = {
canApprove: boolean;
completedItems: string[];
blockers: string[];
draftVersion: number | null;
requestedDraftVersion: number | null;
items: FinalApprovalChecklistItemViewModel[];
};
export function buildFinalApprovalViewModel(
checklist: FinalApprovalChecklistResponse | null,
): FinalApprovalViewModel {
if (!checklist) {
return {
canApprove: false,
completedItems: [],
blockers: [],
draftVersion: null,
requestedDraftVersion: null,
items: [],
};
}
return {
canApprove: checklist.can_approve,
completedItems: checklist.completed_items ?? [],
blockers: checklist.blockers ?? [],
draftVersion: checklist.draft_version ?? null,
requestedDraftVersion: checklist.requested_draft_version ?? null,
items: (checklist.items ?? []).map(mapChecklistItem),
};
}
function mapChecklistItem(
item: FinalApprovalChecklistItemSummary,
): FinalApprovalChecklistItemViewModel {
return {
key: item.key,
title: item.title,
completed: item.completed,
blocker: item.blocker ?? "",
};
}
@@ -0,0 +1,205 @@
"use client";
import { useMemo, useState } from "react";
import type { FinalApprovalChecklistResponse } from "@pipeline/shared";
import {
ApiError,
fetchFinalApprovalChecklist,
submitFinalApproval,
submitFinalRevisionRequest,
} from "@/shared/pipeline-api";
import { buildFinalApprovalViewModel } from "./model";
type FinalApprovalPanelProps = {
articleId: string;
};
const DEFAULT_FRONTMATTER = '{\n "category": "Guides"\n}';
export function FinalApprovalPanel({ articleId }: FinalApprovalPanelProps) {
const [checklist, setChecklist] = useState<FinalApprovalChecklistResponse | null>(null);
const [draftVersionInput, setDraftVersionInput] = useState("");
const [contentPath, setContentPath] = useState("/guides/final-approval-gate");
const [author, setAuthor] = useState("Editorial Team");
const [publishingMode, setPublishingMode] = useState("MANUAL");
const [frontmatterText, setFrontmatterText] = useState(DEFAULT_FRONTMATTER);
const [revisionReason, setRevisionReason] = useState("Need final refinements before approval.");
const [busyKey, setBusyKey] = useState<string | null>(null);
const [message, setMessage] = useState("");
const viewModel = useMemo(
() => buildFinalApprovalViewModel(checklist),
[checklist],
);
async function refreshChecklist() {
setBusyKey("refresh");
setMessage("");
try {
const response = await fetchFinalApprovalChecklist(articleId);
setChecklist(response);
if (!draftVersionInput && response.draft_version) {
setDraftVersionInput(String(response.draft_version));
}
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Failed to fetch final checklist.");
} finally {
setBusyKey(null);
}
}
async function approve() {
let frontmatter: Record<string, unknown> | null = null;
try {
const parsed = JSON.parse(frontmatterText);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
frontmatter = parsed as Record<string, unknown>;
}
} catch {
setMessage("Frontmatter must be a valid JSON object.");
return;
}
const parsedDraftVersion = Number.parseInt(draftVersionInput, 10);
setBusyKey("approve");
setMessage("");
try {
const response = await submitFinalApproval(articleId, {
draft_version: Number.isNaN(parsedDraftVersion) ? null : parsedDraftVersion,
publishing_settings: {
content_path: contentPath,
author,
publishing_mode: publishingMode,
frontmatter,
},
});
setChecklist(response.checklist);
setMessage(`Final approval completed. Article status: ${response.article.status}.`);
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Final approval failed.");
await refreshChecklist();
} finally {
setBusyKey(null);
}
}
async function requestRevision() {
const parsedDraftVersion = Number.parseInt(draftVersionInput, 10);
setBusyKey("revision");
setMessage("");
try {
const response = await submitFinalRevisionRequest(articleId, {
reason: revisionReason,
draft_version: Number.isNaN(parsedDraftVersion) ? null : parsedDraftVersion,
});
setChecklist(response.checklist);
setMessage(`Revision requested. Article status: ${response.article.status}.`);
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Failed to request revision.");
} finally {
setBusyKey(null);
}
}
return (
<section className="reviewPanel">
<div className="sectionHeader">
<div>
<h2>Final approval gate</h2>
<p>Checklist blockers: {viewModel.blockers.length}</p>
</div>
<div className="formActions">
<button type="button" onClick={() => void refreshChecklist()} disabled={busyKey !== null}>
{busyKey === "refresh" ? "Refreshing..." : "Refresh checklist"}
</button>
</div>
</div>
{message ? <p className={message.includes("failed") ? "formError" : "formSuccess"}>{message}</p> : null}
{viewModel.items.length > 0 ? (
<table className="table">
<thead>
<tr>
<th>Checklist item</th>
<th>Status</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{viewModel.items.map((item) => (
<tr key={item.key}>
<td>{item.title}</td>
<td>{item.completed ? "Completed" : "Blocked"}</td>
<td>{item.completed ? "—" : item.blocker || "—"}</td>
</tr>
))}
</tbody>
</table>
) : (
<p>No checklist yet. Refresh to load current gate status.</p>
)}
{viewModel.completedItems.length > 0 ? (
<ul className="timeline">
{viewModel.completedItems.map((item) => (
<li key={item}>Completed: {item}</li>
))}
</ul>
) : null}
{viewModel.blockers.length > 0 ? (
<ul className="timeline">
{viewModel.blockers.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
) : null}
<div className="reviewActions">
<input
value={draftVersionInput}
onChange={(event) => setDraftVersionInput(event.target.value)}
placeholder={viewModel.draftVersion ? String(viewModel.draftVersion) : "Draft version"}
inputMode="numeric"
/>
<input
value={contentPath}
onChange={(event) => setContentPath(event.target.value)}
placeholder="/guides/final-approval-gate"
/>
<input
value={author}
onChange={(event) => setAuthor(event.target.value)}
placeholder="Author"
/>
<input
value={publishingMode}
onChange={(event) => setPublishingMode(event.target.value)}
placeholder="MANUAL"
/>
<textarea
value={frontmatterText}
onChange={(event) => setFrontmatterText(event.target.value)}
rows={4}
/>
<button type="button" onClick={() => void approve()} disabled={busyKey !== null}>
{busyKey === "approve" ? "Approving..." : "Approve final"}
</button>
</div>
<div className="reviewActions">
<input
value={revisionReason}
onChange={(event) => setRevisionReason(event.target.value)}
placeholder="Revision reason"
/>
<button type="button" onClick={() => void requestRevision()} disabled={busyKey !== null}>
{busyKey === "revision" ? "Submitting..." : "Request revision"}
</button>
</div>
</section>
);
}
@@ -8,6 +8,7 @@ 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 { FinalApprovalPanel } from "@/features/final-approval/ui";
import { fetchArticleDetail, fetchDrafts } from "@/shared/pipeline-api";
import { ApiError } from "@/shared/pipeline-api";
@@ -44,6 +45,7 @@ export default async function ArticleDetailPage({
assets={detail.assets ?? []}
/>
<ArticleReviewPanel articleId={articleId} />
<FinalApprovalPanel articleId={articleId} />
</section>
</main>
);
+33
View File
@@ -24,6 +24,11 @@ import type {
EvidenceCreateRequest,
EvidenceUpdateRequest,
FinalReviewIssuesResponse,
FinalApprovalChecklistResponse,
FinalApprovalRequest,
FinalApprovalResponse,
FinalRevisionRequest,
FinalRevisionRequestResponse,
LanguageReviewReportResponse,
LanguageReviewRunResponse,
PlanListResponse,
@@ -622,3 +627,31 @@ export function fetchFinalReviewIssues(
): Promise<FinalReviewIssuesResponse> {
return apiGet<FinalReviewIssuesResponse>(`/api/articles/${articleId}/final-review/issues`);
}
export function fetchFinalApprovalChecklist(
articleId: string,
): Promise<FinalApprovalChecklistResponse> {
return apiGet<FinalApprovalChecklistResponse>(
`/api/articles/${articleId}/final-approval/checklist`,
);
}
export function submitFinalApproval(
articleId: string,
request: FinalApprovalRequest,
): Promise<FinalApprovalResponse> {
return apiPost<FinalApprovalRequest, FinalApprovalResponse>(
`/api/articles/${articleId}/final-approval`,
request,
);
}
export function submitFinalRevisionRequest(
articleId: string,
request: FinalRevisionRequest,
): Promise<FinalRevisionRequestResponse> {
return apiPost<FinalRevisionRequest, FinalRevisionRequestResponse>(
`/api/articles/${articleId}/final-revision-request`,
request,
);
}
@@ -0,0 +1,53 @@
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/final-approval/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 { buildFinalApprovalViewModel } = moduleExports;
const viewModel = buildFinalApprovalViewModel({
article_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
draft_version: 3,
requested_draft_version: 2,
can_approve: false,
completed_items: ["Plan followed", "Evidence reviewed"],
blockers: [
"Final approval requires latest draft version 3.",
"Required publishing settings are missing: content path.",
],
items: [
{
key: "exact_draft_version",
title: "Exact draft version selected",
completed: false,
blocker: "Final approval requires latest draft version 3.",
},
{
key: "plan_followed",
title: "Plan followed",
completed: true,
blocker: null,
},
],
});
assert.equal(viewModel.canApprove, false);
assert.equal(viewModel.draftVersion, 3);
assert.equal(viewModel.requestedDraftVersion, 2);
assert.equal(viewModel.completedItems.length, 2);
assert.equal(viewModel.blockers.length, 2);
assert.equal(viewModel.items.length, 2);
assert.equal(viewModel.items[0].completed, false);
assert.equal(viewModel.items[1].completed, true);