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