Task 002: add shared domain contracts
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
from src.domain.contracts import (
|
||||
ArticleCreateRequest,
|
||||
ArticleCreateResponse,
|
||||
ArticleSummary,
|
||||
ArticleWorkflowStatus,
|
||||
PublishingStatus,
|
||||
)
|
||||
|
||||
|
||||
PLACEHOLDER_CREATED_AT = datetime(1970, 1, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
def build_placeholder_article_response(
|
||||
request: ArticleCreateRequest,
|
||||
) -> ArticleCreateResponse:
|
||||
article_id = uuid5(
|
||||
NAMESPACE_URL,
|
||||
f"article:{request.target_site_id}:{request.brief_description}",
|
||||
)
|
||||
article = ArticleSummary(
|
||||
id=article_id,
|
||||
target_site_id=request.target_site_id,
|
||||
status=ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
|
||||
publishing_status=PublishingStatus.PUBLISH_NOT_STARTED,
|
||||
brief_description=request.brief_description,
|
||||
working_title=request.working_title,
|
||||
language=request.language,
|
||||
content_type=request.content_type,
|
||||
primary_keyword=request.primary_keyword,
|
||||
assigned_editor_id=request.assigned_editor_id,
|
||||
created_at=PLACEHOLDER_CREATED_AT,
|
||||
updated_at=PLACEHOLDER_CREATED_AT,
|
||||
)
|
||||
return ArticleCreateResponse(article=article)
|
||||
@@ -0,0 +1,87 @@
|
||||
from .enums import (
|
||||
AgentJobErrorCategory,
|
||||
AgentJobStatus,
|
||||
AgentJobType,
|
||||
ArticleWorkflowStatus,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
ClaimRiskLevel,
|
||||
ClaimSupportStatus,
|
||||
PlanReviewStatus,
|
||||
PublishingStatus,
|
||||
ReviewStatus,
|
||||
ReviewType,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
)
|
||||
from .models import (
|
||||
AgentJobOutput,
|
||||
AgentJobSummary,
|
||||
ArticleCreateRequest,
|
||||
ArticleCreateResponse,
|
||||
ArticleDetailResponse,
|
||||
ArticleListResponse,
|
||||
ArticleSummary,
|
||||
AssetSummary,
|
||||
ClaimSummary,
|
||||
DraftSummary,
|
||||
EvidenceSummary,
|
||||
PlanSectionSummary,
|
||||
PlanSummary,
|
||||
PublishCommitSummary,
|
||||
PublishingRules,
|
||||
ResearchArtifactManifestSummary,
|
||||
ResearchArtifactSummary,
|
||||
ReviewSummary,
|
||||
RunnerFileRef,
|
||||
ScriptConfigVersionSummary,
|
||||
TargetSiteConfig,
|
||||
UserSummary,
|
||||
)
|
||||
from .workflow import (
|
||||
ARTICLE_WORKFLOW_STATUSES,
|
||||
ARTICLE_WORKFLOW_TRANSITIONS,
|
||||
next_workflow_statuses,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ARTICLE_WORKFLOW_STATUSES",
|
||||
"ARTICLE_WORKFLOW_TRANSITIONS",
|
||||
"AgentJobErrorCategory",
|
||||
"AgentJobOutput",
|
||||
"AgentJobStatus",
|
||||
"AgentJobSummary",
|
||||
"AgentJobType",
|
||||
"ArticleCreateRequest",
|
||||
"ArticleCreateResponse",
|
||||
"ArticleDetailResponse",
|
||||
"ArticleListResponse",
|
||||
"ArticleSummary",
|
||||
"ArticleWorkflowStatus",
|
||||
"AssetStatus",
|
||||
"AssetSummary",
|
||||
"AssetType",
|
||||
"ClaimRiskLevel",
|
||||
"ClaimSummary",
|
||||
"ClaimSupportStatus",
|
||||
"DraftSummary",
|
||||
"EvidenceSummary",
|
||||
"PlanReviewStatus",
|
||||
"PlanSectionSummary",
|
||||
"PlanSummary",
|
||||
"PublishCommitSummary",
|
||||
"PublishingRules",
|
||||
"PublishingStatus",
|
||||
"ResearchArtifactManifestSummary",
|
||||
"ResearchArtifactSummary",
|
||||
"ReviewStatus",
|
||||
"ReviewSummary",
|
||||
"ReviewType",
|
||||
"Role",
|
||||
"RunnerFileRef",
|
||||
"ScriptConfigVersionStatus",
|
||||
"ScriptConfigVersionSummary",
|
||||
"TargetSiteConfig",
|
||||
"UserSummary",
|
||||
"next_workflow_statuses",
|
||||
]
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Role(str, Enum):
|
||||
ADMIN = "ADMIN"
|
||||
EDITOR = "EDITOR"
|
||||
|
||||
|
||||
class ArticleWorkflowStatus(str, Enum):
|
||||
ARTICLE_BRIEF_CREATED = "ARTICLE_BRIEF_CREATED"
|
||||
BOUNDARY_QUESTIONS_GENERATED = "BOUNDARY_QUESTIONS_GENERATED"
|
||||
BOUNDARY_ANSWERS_SUBMITTED = "BOUNDARY_ANSWERS_SUBMITTED"
|
||||
PLAN_GENERATED = "PLAN_GENERATED"
|
||||
PLAN_REVIEW_REQUIRED = "PLAN_REVIEW_REQUIRED"
|
||||
PLAN_REVISION_REQUIRED = "PLAN_REVISION_REQUIRED"
|
||||
RESEARCH_RUNNING = "RESEARCH_RUNNING"
|
||||
EVIDENCE_MATRIX_READY = "EVIDENCE_MATRIX_READY"
|
||||
PARALLEL_PRODUCTION_RUNNING = "PARALLEL_PRODUCTION_RUNNING"
|
||||
DRAFT_ASSEMBLED = "DRAFT_ASSEMBLED"
|
||||
SEO_AND_LANGUAGE_REVIEW_READY = "SEO_AND_LANGUAGE_REVIEW_READY"
|
||||
FINAL_REVIEW_REQUIRED = "FINAL_REVIEW_REQUIRED"
|
||||
FINAL_REVISION_REQUIRED = "FINAL_REVISION_REQUIRED"
|
||||
PUBLISH_DRY_RUN_REQUIRED = "PUBLISH_DRY_RUN_REQUIRED"
|
||||
PUBLISH_COMMIT_READY = "PUBLISH_COMMIT_READY"
|
||||
PUBLISH_COMMIT_CREATED = "PUBLISH_COMMIT_CREATED"
|
||||
|
||||
|
||||
class AgentJobStatus(str, Enum):
|
||||
QUEUED = "QUEUED"
|
||||
RUNNING = "RUNNING"
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
FAILED = "FAILED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
class AgentJobType(str, Enum):
|
||||
BOUNDARY_QUESTIONS = "BOUNDARY_QUESTIONS"
|
||||
PLAN_GENERATION = "PLAN_GENERATION"
|
||||
RESEARCH = "RESEARCH"
|
||||
EVIDENCE_MATRIX = "EVIDENCE_MATRIX"
|
||||
SECTION_SCAFFOLD = "SECTION_SCAFFOLD"
|
||||
DRAFT_ASSEMBLY = "DRAFT_ASSEMBLY"
|
||||
SEO_REVIEW = "SEO_REVIEW"
|
||||
LANGUAGE_REVIEW = "LANGUAGE_REVIEW"
|
||||
PUBLISH_DRY_RUN = "PUBLISH_DRY_RUN"
|
||||
PUBLISH_COMMIT = "PUBLISH_COMMIT"
|
||||
TEST_CODEX = "TEST_CODEX"
|
||||
|
||||
|
||||
class AgentJobErrorCategory(str, Enum):
|
||||
FAILED_SCHEMA_VALIDATION = "FAILED_SCHEMA_VALIDATION"
|
||||
CLI_EXIT_CODE_FAILURE = "CLI_EXIT_CODE_FAILURE"
|
||||
TIMEOUT = "TIMEOUT"
|
||||
MISSING_OUTPUT_FILE = "MISSING_OUTPUT_FILE"
|
||||
UNSUPPORTED_CLAIMS_FOUND = "UNSUPPORTED_CLAIMS_FOUND"
|
||||
SOURCE_RETRIEVAL_FAILED = "SOURCE_RETRIEVAL_FAILED"
|
||||
RESEARCH_ARTIFACT_UPLOAD_FAILED = "RESEARCH_ARTIFACT_UPLOAD_FAILED"
|
||||
PUBLISH_DRY_RUN_FAILED = "PUBLISH_DRY_RUN_FAILED"
|
||||
GIT_CHECKOUT_FAILED = "GIT_CHECKOUT_FAILED"
|
||||
GIT_COMMIT_FAILED = "GIT_COMMIT_FAILED"
|
||||
GIT_PUSH_NON_FAST_FORWARD = "GIT_PUSH_NON_FAST_FORWARD"
|
||||
PUBLISH_VERIFICATION_FAILED = "PUBLISH_VERIFICATION_FAILED"
|
||||
|
||||
|
||||
class ClaimSupportStatus(str, Enum):
|
||||
SUPPORTED = "SUPPORTED"
|
||||
UNSUPPORTED = "UNSUPPORTED"
|
||||
NEEDS_REVIEW = "NEEDS_REVIEW"
|
||||
|
||||
|
||||
class ClaimRiskLevel(str, Enum):
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
|
||||
|
||||
class PublishingStatus(str, Enum):
|
||||
PUBLISH_NOT_STARTED = "PUBLISH_NOT_STARTED"
|
||||
PUBLISH_DRY_RUN_REQUIRED = "PUBLISH_DRY_RUN_REQUIRED"
|
||||
PUBLISH_DRY_RUN_RUNNING = "PUBLISH_DRY_RUN_RUNNING"
|
||||
PUBLISH_DRY_RUN_FAILED = "PUBLISH_DRY_RUN_FAILED"
|
||||
PUBLISH_COMMIT_READY = "PUBLISH_COMMIT_READY"
|
||||
PUBLISH_COMMIT_CREATED = "PUBLISH_COMMIT_CREATED"
|
||||
PUBLISH_VERIFICATION_FAILED = "PUBLISH_VERIFICATION_FAILED"
|
||||
|
||||
|
||||
class AssetStatus(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
GENERATED = "GENERATED"
|
||||
APPROVED = "APPROVED"
|
||||
REJECTED = "REJECTED"
|
||||
|
||||
|
||||
class AssetType(str, Enum):
|
||||
HERO_IMAGE = "hero_image"
|
||||
DIAGRAM = "diagram"
|
||||
TABLE = "table"
|
||||
INLINE_IMAGE = "inline_image"
|
||||
|
||||
|
||||
class PlanReviewStatus(str, Enum):
|
||||
PENDING_REVIEW = "PENDING_REVIEW"
|
||||
APPROVED = "APPROVED"
|
||||
REVISION_REQUESTED = "REVISION_REQUESTED"
|
||||
|
||||
|
||||
class ReviewStatus(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
APPROVED = "APPROVED"
|
||||
CHANGES_REQUESTED = "CHANGES_REQUESTED"
|
||||
|
||||
|
||||
class ReviewType(str, Enum):
|
||||
PLAN = "PLAN"
|
||||
EVIDENCE = "EVIDENCE"
|
||||
ASSET = "ASSET"
|
||||
SEO_LANGUAGE = "SEO_LANGUAGE"
|
||||
FINAL = "FINAL"
|
||||
|
||||
|
||||
class ScriptConfigVersionStatus(str, Enum):
|
||||
DRAFT = "DRAFT"
|
||||
ACTIVE = "ACTIVE"
|
||||
DEPRECATED = "DEPRECATED"
|
||||
@@ -0,0 +1,266 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .enums import (
|
||||
AgentJobErrorCategory,
|
||||
AgentJobStatus,
|
||||
AgentJobType,
|
||||
ArticleWorkflowStatus,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
ClaimRiskLevel,
|
||||
ClaimSupportStatus,
|
||||
PlanReviewStatus,
|
||||
PublishingStatus,
|
||||
ReviewStatus,
|
||||
ReviewType,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
)
|
||||
|
||||
|
||||
JsonObject = dict[str, Any]
|
||||
|
||||
|
||||
class ContractModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class UserSummary(ContractModel):
|
||||
id: UUID
|
||||
display_name: str = Field(min_length=1)
|
||||
role: Role
|
||||
|
||||
|
||||
class PublishingRules(ContractModel):
|
||||
repository_url: str = Field(min_length=1)
|
||||
production_branch: str = Field(min_length=1)
|
||||
content_format: str = Field(default="mdx", min_length=1)
|
||||
content_path_template: str = Field(min_length=1)
|
||||
asset_path_template: str = Field(min_length=1)
|
||||
frontmatter_mapping: JsonObject = Field(default_factory=dict)
|
||||
transform_script_version_id: UUID | None = None
|
||||
dry_run_renderer: str | None = None
|
||||
|
||||
|
||||
class TargetSiteConfig(ContractModel):
|
||||
id: UUID
|
||||
name: str = Field(min_length=1)
|
||||
slug: str = Field(min_length=1)
|
||||
publishing_type: str = Field(default="git_next", min_length=1)
|
||||
default_language: str = Field(default="en", min_length=2)
|
||||
brand_voice: str = Field(min_length=1)
|
||||
audience: str = Field(min_length=1)
|
||||
seo_rules: JsonObject = Field(default_factory=dict)
|
||||
visual_rules: JsonObject = Field(default_factory=dict)
|
||||
source_rules: JsonObject = Field(default_factory=dict)
|
||||
publishing_rules: PublishingRules
|
||||
active_script_config_version_id: UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ScriptConfigVersionSummary(ContractModel):
|
||||
id: UUID
|
||||
target_site_id: UUID
|
||||
version: int = Field(ge=1)
|
||||
status: ScriptConfigVersionStatus
|
||||
publishing_yaml_hash: str = Field(min_length=1)
|
||||
transform_script_hash: str = Field(min_length=1)
|
||||
created_by: UUID
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ArticleCreateRequest(ContractModel):
|
||||
target_site_id: UUID
|
||||
brief_description: str = Field(min_length=1)
|
||||
working_title: str | None = None
|
||||
language: str = Field(default="en", min_length=2)
|
||||
content_type: str = Field(default="longform_guide", min_length=1)
|
||||
primary_keyword: str | None = None
|
||||
assigned_editor_id: UUID | None = None
|
||||
|
||||
|
||||
class ArticleSummary(ContractModel):
|
||||
id: UUID
|
||||
target_site_id: UUID
|
||||
status: ArticleWorkflowStatus
|
||||
publishing_status: PublishingStatus
|
||||
brief_description: str
|
||||
working_title: str | None = None
|
||||
language: str
|
||||
content_type: str
|
||||
primary_keyword: str | None = None
|
||||
assigned_editor_id: UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ArticleCreateResponse(ContractModel):
|
||||
article: ArticleSummary
|
||||
|
||||
|
||||
class ArticleListResponse(ContractModel):
|
||||
articles: list[ArticleSummary]
|
||||
|
||||
|
||||
class PlanSectionSummary(ContractModel):
|
||||
id: UUID
|
||||
article_plan_id: UUID
|
||||
sort_order: int = Field(ge=1)
|
||||
heading: str = Field(min_length=1)
|
||||
purpose: str | None = None
|
||||
claims_to_support: list[str] = Field(default_factory=list)
|
||||
target_word_count: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class PlanSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
version: int = Field(ge=1)
|
||||
status: PlanReviewStatus
|
||||
recommended_title: str | None = None
|
||||
search_intent: str | None = None
|
||||
thesis: str | None = None
|
||||
sections: list[PlanSectionSummary] = Field(default_factory=list)
|
||||
claims_to_prove: list[str] = Field(default_factory=list)
|
||||
risks: list[str] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ResearchArtifactSummary(ContractModel):
|
||||
artifact_type: str = Field(min_length=1)
|
||||
source_url: str = Field(min_length=1)
|
||||
object_key: str = Field(min_length=1)
|
||||
content_hash: str = Field(min_length=1)
|
||||
metadata: JsonObject = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ResearchArtifactManifestSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
agent_job_id: UUID
|
||||
s3_prefix: str = Field(min_length=1)
|
||||
artifacts: list[ResearchArtifactSummary] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EvidenceSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
source_title: str = Field(min_length=1)
|
||||
source_url: str = Field(min_length=1)
|
||||
source_type: str = Field(min_length=1)
|
||||
source_quality_score: float = Field(ge=0, le=1)
|
||||
summary: str = Field(min_length=1)
|
||||
supports_claims: list[UUID] = Field(default_factory=list)
|
||||
artifact_manifest_id: UUID
|
||||
retrieved_at: datetime
|
||||
|
||||
|
||||
class ClaimSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
section_id: UUID | None = None
|
||||
claim_text: str = Field(min_length=1)
|
||||
support_status: ClaimSupportStatus
|
||||
risk_level: ClaimRiskLevel
|
||||
evidence_item_ids: list[UUID] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DraftSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
version: int = Field(ge=1)
|
||||
title: str = Field(min_length=1)
|
||||
slug: str = Field(min_length=1)
|
||||
meta_title: str | None = None
|
||||
meta_description: str | None = None
|
||||
body_object_key: str | None = None
|
||||
status: ArticleWorkflowStatus
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AssetSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
asset_type: AssetType
|
||||
title: str = Field(min_length=1)
|
||||
prompt: str | None = None
|
||||
file_url: str | None = None
|
||||
alt_text: str | None = None
|
||||
caption: str | None = None
|
||||
status: AssetStatus
|
||||
|
||||
|
||||
class ReviewSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
review_type: ReviewType
|
||||
status: ReviewStatus
|
||||
reviewer_id: UUID | None = None
|
||||
notes: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PublishCommitSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
target_site_id: UUID
|
||||
repository_url: str = Field(min_length=1)
|
||||
branch: str = Field(min_length=1)
|
||||
commit_sha: str | None = None
|
||||
content_bundle_manifest: JsonObject = Field(default_factory=dict)
|
||||
status: PublishingStatus
|
||||
deployment_status: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RunnerFileRef(ContractModel):
|
||||
path: str = Field(min_length=1)
|
||||
content_hash: str | None = None
|
||||
|
||||
|
||||
class AgentJobSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
job_type: AgentJobType
|
||||
agent_profile: str = Field(min_length=1)
|
||||
status: AgentJobStatus
|
||||
workspace_path: str | None = None
|
||||
input_files: list[RunnerFileRef] = Field(default_factory=list)
|
||||
output_files: list[RunnerFileRef] = Field(default_factory=list)
|
||||
error_category: AgentJobErrorCategory | None = None
|
||||
error_message: str | None = None
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
|
||||
|
||||
class AgentJobOutput(ContractModel):
|
||||
status: AgentJobStatus
|
||||
output_files: list[RunnerFileRef] = Field(default_factory=list)
|
||||
error_category: AgentJobErrorCategory | None = None
|
||||
error_message: str | None = None
|
||||
artifacts: list[ResearchArtifactSummary] = Field(default_factory=list)
|
||||
payload: JsonObject = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ArticleDetailResponse(ContractModel):
|
||||
article: ArticleSummary
|
||||
target_site: TargetSiteConfig | None = None
|
||||
plan: PlanSummary | None = None
|
||||
draft: DraftSummary | None = None
|
||||
evidence: list[EvidenceSummary] = Field(default_factory=list)
|
||||
claims: list[ClaimSummary] = Field(default_factory=list)
|
||||
assets: list[AssetSummary] = Field(default_factory=list)
|
||||
reviews: list[ReviewSummary] = Field(default_factory=list)
|
||||
agent_jobs: list[AgentJobSummary] = Field(default_factory=list)
|
||||
research_manifests: list[ResearchArtifactManifestSummary] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
publish_commit: PublishCommitSummary | None = None
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
|
||||
from .enums import (
|
||||
AgentJobErrorCategory,
|
||||
AgentJobStatus,
|
||||
AgentJobType,
|
||||
ArticleWorkflowStatus,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
ClaimRiskLevel,
|
||||
ClaimSupportStatus,
|
||||
PlanReviewStatus,
|
||||
PublishingStatus,
|
||||
ReviewStatus,
|
||||
ReviewType,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
)
|
||||
from .models import (
|
||||
AgentJobOutput,
|
||||
AgentJobSummary,
|
||||
ArticleCreateRequest,
|
||||
ArticleCreateResponse,
|
||||
ArticleDetailResponse,
|
||||
ArticleListResponse,
|
||||
ArticleSummary,
|
||||
AssetSummary,
|
||||
ClaimSummary,
|
||||
DraftSummary,
|
||||
EvidenceSummary,
|
||||
PlanSectionSummary,
|
||||
PlanSummary,
|
||||
PublishCommitSummary,
|
||||
PublishingRules,
|
||||
ResearchArtifactManifestSummary,
|
||||
ResearchArtifactSummary,
|
||||
ReviewSummary,
|
||||
RunnerFileRef,
|
||||
ScriptConfigVersionSummary,
|
||||
TargetSiteConfig,
|
||||
UserSummary,
|
||||
)
|
||||
|
||||
|
||||
CONTRACT_ENUMS: tuple[type[Enum], ...] = (
|
||||
Role,
|
||||
ArticleWorkflowStatus,
|
||||
AgentJobStatus,
|
||||
AgentJobType,
|
||||
AgentJobErrorCategory,
|
||||
ClaimSupportStatus,
|
||||
ClaimRiskLevel,
|
||||
PublishingStatus,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
PlanReviewStatus,
|
||||
ReviewStatus,
|
||||
ReviewType,
|
||||
ScriptConfigVersionStatus,
|
||||
)
|
||||
|
||||
CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
||||
UserSummary,
|
||||
PublishingRules,
|
||||
TargetSiteConfig,
|
||||
ScriptConfigVersionSummary,
|
||||
ArticleCreateRequest,
|
||||
ArticleSummary,
|
||||
ArticleCreateResponse,
|
||||
ArticleListResponse,
|
||||
PlanSectionSummary,
|
||||
PlanSummary,
|
||||
ResearchArtifactSummary,
|
||||
ResearchArtifactManifestSummary,
|
||||
EvidenceSummary,
|
||||
ClaimSummary,
|
||||
DraftSummary,
|
||||
AssetSummary,
|
||||
ReviewSummary,
|
||||
PublishCommitSummary,
|
||||
RunnerFileRef,
|
||||
AgentJobSummary,
|
||||
AgentJobOutput,
|
||||
ArticleDetailResponse,
|
||||
)
|
||||
|
||||
|
||||
def inject_contract_schemas(openapi_schema: dict[str, Any]) -> None:
|
||||
schemas = openapi_schema.setdefault("components", {}).setdefault("schemas", {})
|
||||
|
||||
for enum in CONTRACT_ENUMS:
|
||||
schemas[enum.__name__] = TypeAdapter(enum).json_schema(
|
||||
ref_template="#/components/schemas/{model}"
|
||||
)
|
||||
|
||||
for model in CONTRACT_SCHEMA_MODELS:
|
||||
schema = model.model_json_schema(ref_template="#/components/schemas/{model}")
|
||||
for name, definition in schema.pop("$defs", {}).items():
|
||||
schemas.setdefault(name, definition)
|
||||
schemas[model.__name__] = schema
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .enums import ArticleWorkflowStatus
|
||||
|
||||
|
||||
ARTICLE_WORKFLOW_STATUSES: tuple[ArticleWorkflowStatus, ...] = (
|
||||
ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
|
||||
ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED,
|
||||
ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
|
||||
ArticleWorkflowStatus.PLAN_GENERATED,
|
||||
ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED,
|
||||
ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
|
||||
ArticleWorkflowStatus.RESEARCH_RUNNING,
|
||||
ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
|
||||
ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING,
|
||||
ArticleWorkflowStatus.DRAFT_ASSEMBLED,
|
||||
ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
|
||||
ArticleWorkflowStatus.FINAL_REVIEW_REQUIRED,
|
||||
ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
|
||||
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
|
||||
ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
|
||||
ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED,
|
||||
)
|
||||
|
||||
|
||||
ARTICLE_WORKFLOW_TRANSITIONS: dict[
|
||||
ArticleWorkflowStatus, tuple[ArticleWorkflowStatus, ...]
|
||||
] = {
|
||||
ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED: (
|
||||
ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED,
|
||||
),
|
||||
ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED: (
|
||||
ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
|
||||
),
|
||||
ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED: (
|
||||
ArticleWorkflowStatus.PLAN_GENERATED,
|
||||
),
|
||||
ArticleWorkflowStatus.PLAN_GENERATED: (
|
||||
ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED,
|
||||
),
|
||||
ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED: (
|
||||
ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
|
||||
ArticleWorkflowStatus.RESEARCH_RUNNING,
|
||||
),
|
||||
ArticleWorkflowStatus.PLAN_REVISION_REQUIRED: (
|
||||
ArticleWorkflowStatus.PLAN_GENERATED,
|
||||
),
|
||||
ArticleWorkflowStatus.RESEARCH_RUNNING: (
|
||||
ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
|
||||
),
|
||||
ArticleWorkflowStatus.EVIDENCE_MATRIX_READY: (
|
||||
ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING,
|
||||
),
|
||||
ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING: (
|
||||
ArticleWorkflowStatus.DRAFT_ASSEMBLED,
|
||||
),
|
||||
ArticleWorkflowStatus.DRAFT_ASSEMBLED: (
|
||||
ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
|
||||
),
|
||||
ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY: (
|
||||
ArticleWorkflowStatus.FINAL_REVIEW_REQUIRED,
|
||||
),
|
||||
ArticleWorkflowStatus.FINAL_REVIEW_REQUIRED: (
|
||||
ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
|
||||
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
|
||||
),
|
||||
ArticleWorkflowStatus.FINAL_REVISION_REQUIRED: (
|
||||
ArticleWorkflowStatus.DRAFT_ASSEMBLED,
|
||||
),
|
||||
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED: (
|
||||
ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
|
||||
),
|
||||
ArticleWorkflowStatus.PUBLISH_COMMIT_READY: (
|
||||
ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED,
|
||||
),
|
||||
ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED: (),
|
||||
}
|
||||
|
||||
|
||||
def next_workflow_statuses(
|
||||
status: ArticleWorkflowStatus,
|
||||
) -> tuple[ArticleWorkflowStatus, ...]:
|
||||
return ARTICLE_WORKFLOW_TRANSITIONS[status]
|
||||
@@ -1,10 +1,15 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from src.infrastructure.dependencies import check_dependencies
|
||||
from src.domain.contracts.openapi import inject_contract_schemas
|
||||
from src.presentation.routes.articles import router as articles_router
|
||||
|
||||
|
||||
app = FastAPI(title="AI Content Pipeline Backend")
|
||||
app.include_router(articles_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -14,6 +19,8 @@ def health() -> dict[str, str]:
|
||||
|
||||
@app.get("/health/dependencies")
|
||||
def dependency_health() -> JSONResponse:
|
||||
from src.infrastructure.dependencies import check_dependencies
|
||||
|
||||
dependencies = check_dependencies()
|
||||
is_ok = all(status == "ok" for status in dependencies.values())
|
||||
|
||||
@@ -25,3 +32,20 @@ def dependency_health() -> JSONResponse:
|
||||
"dependencies": dependencies,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def custom_openapi() -> dict[str, Any]:
|
||||
if app.openapi_schema:
|
||||
return app.openapi_schema
|
||||
|
||||
openapi_schema = get_openapi(
|
||||
title=app.title,
|
||||
version=app.version,
|
||||
routes=app.routes,
|
||||
)
|
||||
inject_contract_schemas(openapi_schema)
|
||||
app.openapi_schema = openapi_schema
|
||||
return app.openapi_schema
|
||||
|
||||
|
||||
app.openapi = custom_openapi
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Backend HTTP route modules."""
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
|
||||
from src.application.articles import build_placeholder_article_response
|
||||
from src.domain.contracts import ArticleCreateRequest, ArticleCreateResponse
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["articles"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles",
|
||||
response_model=ArticleCreateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_article(request: ArticleCreateRequest) -> ArticleCreateResponse:
|
||||
return build_placeholder_article_response(request)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from src.domain.contracts import ( # noqa: E402
|
||||
ARTICLE_WORKFLOW_STATUSES,
|
||||
ARTICLE_WORKFLOW_TRANSITIONS,
|
||||
ArticleCreateRequest,
|
||||
ArticleWorkflowStatus,
|
||||
Role,
|
||||
UserSummary,
|
||||
)
|
||||
|
||||
|
||||
class DomainContractTest(unittest.TestCase):
|
||||
def test_role_enum_is_canonical(self) -> None:
|
||||
self.assertEqual(["ADMIN", "EDITOR"], [role.value for role in Role])
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
UserSummary.model_validate(
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"display_name": "Writer",
|
||||
"role": "WRITER",
|
||||
}
|
||||
)
|
||||
|
||||
def test_article_create_rejects_missing_required_fields(self) -> None:
|
||||
with self.assertRaises(ValidationError) as error:
|
||||
ArticleCreateRequest.model_validate({})
|
||||
|
||||
missing_fields = {
|
||||
issue["loc"][0]
|
||||
for issue in error.exception.errors()
|
||||
if issue["type"] == "missing"
|
||||
}
|
||||
self.assertEqual({"brief_description", "target_site_id"}, missing_fields)
|
||||
|
||||
def test_workflow_statuses_and_transitions_are_shared_constants(self) -> None:
|
||||
self.assertEqual(
|
||||
[
|
||||
"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",
|
||||
],
|
||||
[status.value for status in ARTICLE_WORKFLOW_STATUSES],
|
||||
)
|
||||
self.assertEqual(set(ArticleWorkflowStatus), set(ARTICLE_WORKFLOW_TRANSITIONS))
|
||||
for status, next_statuses in ARTICLE_WORKFLOW_TRANSITIONS.items():
|
||||
self.assertIsInstance(status, ArticleWorkflowStatus)
|
||||
for next_status in next_statuses:
|
||||
self.assertIsInstance(next_status, ArticleWorkflowStatus)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
BACKEND_ROOT = REPO_ROOT / "apps" / "backend"
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from src.presentation.main import app # noqa: E402
|
||||
|
||||
|
||||
class GeneratedContractArtifactsTest(unittest.TestCase):
|
||||
def test_openapi_snapshot_matches_backend_openapi(self) -> None:
|
||||
snapshot_path = REPO_ROOT / "packages" / "shared" / "openapi.json"
|
||||
snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertEqual(app.openapi(), snapshot)
|
||||
|
||||
def test_typescript_types_are_marked_generated(self) -> None:
|
||||
types_path = REPO_ROOT / "packages" / "shared" / "src" / "api-types.ts"
|
||||
content = types_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertTrue(
|
||||
content.startswith(
|
||||
"// Generated from backend OpenAPI by "
|
||||
"scripts/generate_openapi_contracts.py."
|
||||
)
|
||||
)
|
||||
self.assertIn("export type ArticleCreateRequest", content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
|
||||
def _module_available(name: str) -> bool:
|
||||
try:
|
||||
return find_spec(name) is not None
|
||||
except (ModuleNotFoundError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _install_optional_dependency_stubs() -> None:
|
||||
# Health routes import external clients; this contract test only reads OpenAPI.
|
||||
if not _module_available("boto3"):
|
||||
boto3 = types.ModuleType("boto3")
|
||||
boto3.client = lambda *args, **kwargs: None
|
||||
sys.modules["boto3"] = boto3
|
||||
|
||||
if not _module_available("psycopg"):
|
||||
psycopg = types.ModuleType("psycopg")
|
||||
psycopg.connect = lambda *args, **kwargs: None
|
||||
sys.modules["psycopg"] = psycopg
|
||||
|
||||
if not _module_available("redis"):
|
||||
redis = types.ModuleType("redis")
|
||||
|
||||
class Redis:
|
||||
@staticmethod
|
||||
def from_url(*args: Any, **kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
redis.Redis = Redis
|
||||
sys.modules["redis"] = redis
|
||||
|
||||
if not _module_available("botocore.config"):
|
||||
botocore = types.ModuleType("botocore")
|
||||
config = types.ModuleType("botocore.config")
|
||||
|
||||
class Config:
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
config.Config = Config
|
||||
sys.modules["botocore"] = botocore
|
||||
sys.modules["botocore.config"] = config
|
||||
|
||||
|
||||
_install_optional_dependency_stubs()
|
||||
|
||||
from src.presentation.main import app # noqa: E402
|
||||
|
||||
|
||||
class BackendPublicOpenApiContractTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.openapi = app.openapi()
|
||||
self.schemas = self.openapi.get("components", {}).get("schemas", {})
|
||||
|
||||
def test_article_create_request_contract_is_public_and_rejects_invalid_payloads(
|
||||
self,
|
||||
) -> None:
|
||||
failures: list[str] = []
|
||||
|
||||
article_schema = self.schemas.get("ArticleCreateRequest")
|
||||
if article_schema is None:
|
||||
failures.append("components.schemas.ArticleCreateRequest is missing")
|
||||
else:
|
||||
required = set(article_schema.get("required", []))
|
||||
for field in ("brief_description", "target_site_id"):
|
||||
if field not in required:
|
||||
failures.append(f"ArticleCreateRequest must require {field!r}")
|
||||
|
||||
invalid_article_errors = self._validation_errors(
|
||||
schema_name="ArticleCreateRequest",
|
||||
payload={},
|
||||
)
|
||||
if not self._has_required_error(invalid_article_errors, "brief_description"):
|
||||
failures.append(
|
||||
"ArticleCreateRequest contract must reject payloads missing "
|
||||
"'brief_description'"
|
||||
)
|
||||
if not self._has_required_error(invalid_article_errors, "target_site_id"):
|
||||
failures.append(
|
||||
"ArticleCreateRequest contract must reject payloads missing "
|
||||
"'target_site_id'"
|
||||
)
|
||||
|
||||
request_ref = self._json_request_schema_ref(path="/api/articles", method="post")
|
||||
if request_ref != "#/components/schemas/ArticleCreateRequest":
|
||||
failures.append(
|
||||
"POST /api/articles must expose ArticleCreateRequest as its JSON "
|
||||
f"request body; got {request_ref or 'no request body'}"
|
||||
)
|
||||
|
||||
role_schema = self.schemas.get("Role")
|
||||
if role_schema is None:
|
||||
failures.append("components.schemas.Role is missing")
|
||||
else:
|
||||
role_values = self._enum_values(role_schema)
|
||||
if role_values != ["ADMIN", "EDITOR"]:
|
||||
failures.append(
|
||||
"Role enum must be exactly ['ADMIN', 'EDITOR']; "
|
||||
f"got {role_values!r}"
|
||||
)
|
||||
if not self._has_enum_error("Role", "WRITER"):
|
||||
failures.append("Role contract must reject invalid value 'WRITER'")
|
||||
|
||||
workflow_status_schema = self.schemas.get("ArticleWorkflowStatus")
|
||||
if workflow_status_schema is None:
|
||||
failures.append("components.schemas.ArticleWorkflowStatus is missing")
|
||||
elif not self._has_enum_error("ArticleWorkflowStatus", "NOT_A_STATUS"):
|
||||
failures.append(
|
||||
"ArticleWorkflowStatus contract must reject invalid workflow statuses"
|
||||
)
|
||||
|
||||
self.assertEqual([], failures)
|
||||
|
||||
def _json_request_schema_ref(self, *, path: str, method: str) -> str | None:
|
||||
operation = self.openapi.get("paths", {}).get(path, {}).get(method, {})
|
||||
content = operation.get("requestBody", {}).get("content", {})
|
||||
schema = content.get("application/json", {}).get("schema", {})
|
||||
return schema.get("$ref")
|
||||
|
||||
def _has_required_error(self, errors: list[str], field: str) -> bool:
|
||||
return any(error == f"missing required field: {field}" for error in errors)
|
||||
|
||||
def _has_enum_error(self, schema_name: str, value: str) -> bool:
|
||||
return f"invalid enum value: {value}" in self._validation_errors(
|
||||
schema_name=schema_name,
|
||||
payload=value,
|
||||
)
|
||||
|
||||
def _validation_errors(self, *, schema_name: str, payload: Any) -> list[str]:
|
||||
schema = self.schemas.get(schema_name)
|
||||
if schema is None:
|
||||
return [f"missing schema: {schema_name}"]
|
||||
|
||||
enum_values = self._enum_values(schema)
|
||||
if enum_values is not None:
|
||||
return [] if payload in enum_values else [f"invalid enum value: {payload}"]
|
||||
|
||||
if schema.get("type") != "object" or not isinstance(payload, dict):
|
||||
return []
|
||||
|
||||
required = schema.get("required", [])
|
||||
return [
|
||||
f"missing required field: {field}"
|
||||
for field in required
|
||||
if field not in payload
|
||||
]
|
||||
|
||||
def _enum_values(self, schema: dict[str, Any]) -> list[str] | None:
|
||||
enum_values = schema.get("enum")
|
||||
if isinstance(enum_values, list):
|
||||
return enum_values
|
||||
|
||||
for composite_key in ("allOf", "anyOf", "oneOf"):
|
||||
for item in schema.get(composite_key, []):
|
||||
resolved = self._resolve_schema(item)
|
||||
values = self._enum_values(resolved)
|
||||
if values is not None:
|
||||
return values
|
||||
|
||||
return None
|
||||
|
||||
def _resolve_schema(self, schema: dict[str, Any]) -> dict[str, Any]:
|
||||
ref = schema.get("$ref")
|
||||
if not isinstance(ref, str):
|
||||
return schema
|
||||
|
||||
prefix = "#/components/schemas/"
|
||||
if not ref.startswith(prefix):
|
||||
return schema
|
||||
|
||||
return self.schemas.get(ref.removeprefix(prefix), schema)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,12 +1,19 @@
|
||||
FROM node:22-alpine
|
||||
FROM node:22-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY apps/frontend/package.json ./package.json
|
||||
RUN npm install
|
||||
RUN corepack enable
|
||||
|
||||
COPY apps/frontend ./
|
||||
COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/frontend/package.json ./apps/frontend/package.json
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY apps/frontend ./apps/frontend
|
||||
COPY packages/shared ./packages/shared
|
||||
|
||||
WORKDIR /app/apps/frontend
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--hostname", "0.0.0.0"]
|
||||
CMD ["pnpm", "exec", "next", "dev", "--hostname", "0.0.0.0"]
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pipeline/shared": "workspace:*",
|
||||
"next": "16.2.6",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
@@ -17,6 +19,10 @@
|
||||
"@types/react-dom": "19.0.2",
|
||||
"typescript": "5.7.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-linux-arm64-gnu": "16.2.6",
|
||||
"@next/swc-linux-x64-gnu": "16.2.6"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss": "8.5.14"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import type { ArticleListResponse } from "@pipeline/shared";
|
||||
|
||||
const initialArticles: ArticleListResponse = {
|
||||
articles: [],
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main>
|
||||
<h1>AI Content Pipeline</h1>
|
||||
<p>Dockerized monorepo foundation is running.</p>
|
||||
<p>
|
||||
{initialArticles.articles.length} articles in the contract-backed dashboard.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY apps/runner/requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY apps/backend/src/domain/contracts ./backend_contracts/contracts
|
||||
COPY apps/runner/src ./src
|
||||
|
||||
EXPOSE 8010
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pydantic==2.13.4
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _backend_contracts_module() -> Any:
|
||||
contracts_dir = _backend_contracts_dir()
|
||||
init_file = contracts_dir / "__init__.py"
|
||||
module_name = "_pipeline_backend_domain_contracts"
|
||||
|
||||
existing = sys.modules.get(module_name)
|
||||
if existing is not None and Path(str(existing.__file__)).resolve() == init_file.resolve():
|
||||
return existing
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name,
|
||||
init_file,
|
||||
submodule_search_locations=[str(contracts_dir)],
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Cannot load backend contract module from {init_file}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _backend_contracts_dir() -> Path:
|
||||
for candidate in _backend_contract_candidates():
|
||||
init_file = candidate / "__init__.py"
|
||||
if init_file.is_file():
|
||||
return candidate
|
||||
|
||||
candidates = ", ".join(str(path) for path in _backend_contract_candidates())
|
||||
raise RuntimeError(f"Cannot find backend contract package. Checked: {candidates}")
|
||||
|
||||
|
||||
def _backend_contract_candidates() -> tuple[Path, ...]:
|
||||
configured = os.environ.get("PIPELINE_BACKEND_CONTRACTS_DIR")
|
||||
candidates: list[Path] = []
|
||||
if configured:
|
||||
candidates.append(Path(configured))
|
||||
|
||||
current_file = Path(__file__).resolve()
|
||||
candidates.append(Path("/app/backend_contracts/contracts"))
|
||||
|
||||
for parent in current_file.parents:
|
||||
candidates.append(parent / "apps" / "backend" / "src" / "domain" / "contracts")
|
||||
|
||||
return tuple(dict.fromkeys(candidates))
|
||||
|
||||
|
||||
def validate_agent_job_output(payload: Mapping[str, Any]) -> Any:
|
||||
contracts = _backend_contracts_module()
|
||||
return contracts.AgentJobOutput.model_validate(payload)
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
RUNNER_ROOT = REPO_ROOT / "apps" / "runner"
|
||||
sys.path.insert(0, str(RUNNER_ROOT))
|
||||
|
||||
from src.application.job_output_validation import ( # noqa: E402
|
||||
_backend_contracts_module,
|
||||
validate_agent_job_output,
|
||||
)
|
||||
|
||||
|
||||
class RunnerJobOutputValidationTest(unittest.TestCase):
|
||||
def test_valid_runner_job_output_uses_backend_contracts(self) -> None:
|
||||
output = validate_agent_job_output(
|
||||
{
|
||||
"status": "SUCCEEDED",
|
||||
"output_files": [{"path": "outputs/plan.json"}],
|
||||
"payload": {"artifact": "plan"},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual("SUCCEEDED", output.status.value)
|
||||
|
||||
def test_invalid_runner_job_output_status_is_rejected(self) -> None:
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_agent_job_output({"status": "NOT_A_STATUS"})
|
||||
|
||||
def test_runner_image_layout_can_use_copied_backend_contracts(self) -> None:
|
||||
source_contracts = REPO_ROOT / "apps" / "backend" / "src" / "domain" / "contracts"
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
copied_contracts = Path(temp_dir) / "backend_contracts" / "contracts"
|
||||
shutil.copytree(source_contracts, copied_contracts)
|
||||
|
||||
previous = os.environ.get("PIPELINE_BACKEND_CONTRACTS_DIR")
|
||||
os.environ["PIPELINE_BACKEND_CONTRACTS_DIR"] = str(copied_contracts)
|
||||
_backend_contracts_module.cache_clear()
|
||||
try:
|
||||
output = validate_agent_job_output({"status": "SUCCEEDED"})
|
||||
finally:
|
||||
_backend_contracts_module.cache_clear()
|
||||
if previous is None:
|
||||
os.environ.pop("PIPELINE_BACKEND_CONTRACTS_DIR", None)
|
||||
else:
|
||||
os.environ["PIPELINE_BACKEND_CONTRACTS_DIR"] = previous
|
||||
|
||||
self.assertEqual("SUCCEEDED", output.status.value)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user