feat(task-023): bind workflow templates into demo flow
This commit is contained in:
@@ -11,6 +11,10 @@ from src.domain.contracts import (
|
||||
ArticleWorkflowStatus,
|
||||
CurrentUser,
|
||||
PublishingStatus,
|
||||
WorkflowTemplateSnapshot,
|
||||
WorkflowTemplateStageSnapshot,
|
||||
WorkflowTemplateStatus,
|
||||
WorkflowTemplateSummary,
|
||||
)
|
||||
from src.application.observability import (
|
||||
build_observability_timeline,
|
||||
@@ -24,10 +28,16 @@ def create_article(
|
||||
current_user: CurrentUser,
|
||||
) -> ArticleCreateResponse:
|
||||
repository.target_sites.get_by_id(request.target_site_id)
|
||||
workflow_snapshot = _resolve_workflow_template_snapshot(repository, request)
|
||||
|
||||
now = _now()
|
||||
summary = repository.articles.create(
|
||||
target_site_id=request.target_site_id,
|
||||
workflow_template_id=workflow_snapshot.id if workflow_snapshot else None,
|
||||
workflow_template_version=workflow_snapshot.version if workflow_snapshot else None,
|
||||
workflow_template_snapshot=(
|
||||
workflow_snapshot.model_dump(mode="json") if workflow_snapshot else None
|
||||
),
|
||||
status=ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
|
||||
publishing_status=PublishingStatus.PUBLISH_NOT_STARTED,
|
||||
brief_description=request.brief_description,
|
||||
@@ -62,6 +72,9 @@ def get_article_detail(
|
||||
) -> ArticleDetailResponse:
|
||||
article = repository.articles.get(article_id)
|
||||
target_site = repository.target_sites.get_by_id(article.target_site_id)
|
||||
workflow_template_snapshot = repository.articles.get_workflow_template_snapshot(
|
||||
article_id
|
||||
)
|
||||
workflow_events = repository.articles.list_workflow_events(article_id)
|
||||
boundary_questions = repository.boundary_questions.list_for_article(article_id)
|
||||
plans = repository.article_plans.list_for_article(article_id)
|
||||
@@ -84,6 +97,7 @@ def get_article_detail(
|
||||
return ArticleDetailResponse(
|
||||
article=article,
|
||||
target_site=target_site,
|
||||
workflow_template_snapshot=workflow_template_snapshot,
|
||||
workflow_events=workflow_events,
|
||||
boundary_questions=boundary_questions,
|
||||
plan=plans[-1] if plans else None,
|
||||
@@ -100,3 +114,44 @@ def get_article_detail(
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _resolve_workflow_template_snapshot(
|
||||
repository: object,
|
||||
request: ArticleCreateRequest,
|
||||
) -> WorkflowTemplateSnapshot | None:
|
||||
if request.workflow_template_id is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
workflow = repository.workflow_templates.get(request.workflow_template_id)
|
||||
except LookupError as error:
|
||||
raise ValueError("Invalid workflow_template_id") from error
|
||||
|
||||
if workflow.status != WorkflowTemplateStatus.ACTIVE:
|
||||
raise ValueError("Workflow template must be active")
|
||||
|
||||
return _workflow_template_snapshot(workflow)
|
||||
|
||||
|
||||
def _workflow_template_snapshot(
|
||||
workflow: WorkflowTemplateSummary,
|
||||
) -> WorkflowTemplateSnapshot:
|
||||
return WorkflowTemplateSnapshot(
|
||||
id=workflow.id,
|
||||
name=workflow.name,
|
||||
slug=workflow.slug,
|
||||
version=workflow.version,
|
||||
stage_summary=[
|
||||
WorkflowTemplateStageSnapshot(
|
||||
id=stage.id,
|
||||
stable_key=stage.stable_key,
|
||||
display_name=stage.display_name,
|
||||
position=stage.position,
|
||||
owner_role=stage.owner_role,
|
||||
runner_profile_key=stage.runner_profile_key,
|
||||
requires_human_approval=stage.requires_human_approval,
|
||||
)
|
||||
for stage in workflow.stages
|
||||
],
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from src.domain.contracts import (
|
||||
PublishingRules,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
WorkflowTemplateStatus,
|
||||
)
|
||||
from src.domain.schema import (
|
||||
SEEDED_ADMIN_EMAIL,
|
||||
@@ -22,6 +23,7 @@ from src.domain.schema import (
|
||||
|
||||
|
||||
SEED_TIMESTAMP = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
SEEDED_WORKFLOW_TEMPLATE_SLUG = "demo-article-production"
|
||||
|
||||
|
||||
def seed_reference_data(repository: object) -> None:
|
||||
@@ -125,6 +127,160 @@ assets:
|
||||
transform_script=transform_script,
|
||||
transform_script_hash=_sha256(transform_script),
|
||||
)
|
||||
_seed_demo_workflow_template(repository, admin_id=admin_id)
|
||||
|
||||
|
||||
def _seed_demo_workflow_template(repository: object, *, admin_id: UUID) -> None:
|
||||
existing = next(
|
||||
(
|
||||
workflow
|
||||
for workflow in repository.workflow_templates.list()
|
||||
if workflow.slug == SEEDED_WORKFLOW_TEMPLATE_SLUG
|
||||
),
|
||||
None,
|
||||
)
|
||||
if existing is not None:
|
||||
return
|
||||
|
||||
workflow = repository.workflow_templates.create(
|
||||
name="Demo article production workflow",
|
||||
slug=SEEDED_WORKFLOW_TEMPLATE_SLUG,
|
||||
description="Active demo workflow for article intake, drafting, review, and publishing.",
|
||||
status=WorkflowTemplateStatus.DRAFT,
|
||||
version=1,
|
||||
created_by=admin_id,
|
||||
updated_by=admin_id,
|
||||
created_at=SEED_TIMESTAMP,
|
||||
updated_at=SEED_TIMESTAMP,
|
||||
)
|
||||
for stage in _demo_workflow_stages():
|
||||
repository.workflow_template_stages.create(
|
||||
workflow_id=workflow.id,
|
||||
stable_key=stage["stable_key"],
|
||||
display_name=stage["display_name"],
|
||||
description=stage["description"],
|
||||
position=stage["position"],
|
||||
owner_role=stage["owner_role"],
|
||||
runner_profile_key=stage["runner_profile_key"],
|
||||
required_inputs=stage["required_inputs"],
|
||||
expected_outputs=stage["expected_outputs"],
|
||||
acceptance_criteria=stage["acceptance_criteria"],
|
||||
requires_human_approval=stage["requires_human_approval"],
|
||||
retry_policy=stage["retry_policy"],
|
||||
parts=stage["parts"],
|
||||
updated_by=admin_id,
|
||||
created_at=SEED_TIMESTAMP,
|
||||
updated_at=SEED_TIMESTAMP,
|
||||
)
|
||||
|
||||
repository.workflow_templates.activate(
|
||||
workflow_id=workflow.id,
|
||||
updated_by=admin_id,
|
||||
activated_at=SEED_TIMESTAMP,
|
||||
)
|
||||
|
||||
|
||||
def _demo_workflow_stages() -> list[dict[str, object]]:
|
||||
return [
|
||||
{
|
||||
"stable_key": "intake-boundary-questions",
|
||||
"display_name": "Boundary question intake",
|
||||
"description": "Collect required editorial context before plan generation.",
|
||||
"position": 1,
|
||||
"owner_role": Role.EDITOR,
|
||||
"runner_profile_key": "boundary-question-agent-v1",
|
||||
"required_inputs": ["article_brief", "target_site"],
|
||||
"expected_outputs": ["answered_boundary_questions"],
|
||||
"acceptance_criteria": ["All required boundary questions are answered."],
|
||||
"requires_human_approval": True,
|
||||
"retry_policy": {"max_attempts": 1, "backoff_seconds": 0},
|
||||
"parts": [],
|
||||
},
|
||||
{
|
||||
"stable_key": "plan-review",
|
||||
"display_name": "Plan review",
|
||||
"description": "Generate and approve the article plan before research.",
|
||||
"position": 2,
|
||||
"owner_role": Role.EDITOR,
|
||||
"runner_profile_key": "plan-generation-agent-v1",
|
||||
"required_inputs": ["answered_boundary_questions"],
|
||||
"expected_outputs": ["approved_plan"],
|
||||
"acceptance_criteria": ["Plan is approved by the editor."],
|
||||
"requires_human_approval": True,
|
||||
"retry_policy": {"max_attempts": 2, "backoff_seconds": 30},
|
||||
"parts": [],
|
||||
},
|
||||
{
|
||||
"stable_key": "research-evidence",
|
||||
"display_name": "Research and evidence",
|
||||
"description": "Run research and approve evidence before production starts.",
|
||||
"position": 3,
|
||||
"owner_role": Role.EDITOR,
|
||||
"runner_profile_key": "research-agent-v1",
|
||||
"required_inputs": ["approved_plan"],
|
||||
"expected_outputs": ["approved_evidence_matrix"],
|
||||
"acceptance_criteria": ["Evidence matrix is ready and approved."],
|
||||
"requires_human_approval": True,
|
||||
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
|
||||
"parts": [],
|
||||
},
|
||||
{
|
||||
"stable_key": "parallel-production",
|
||||
"display_name": "Parallel production",
|
||||
"description": "Create section scaffolds and draft-ready assets in parallel.",
|
||||
"position": 4,
|
||||
"owner_role": Role.EDITOR,
|
||||
"runner_profile_key": "section-scaffold-agent-v1",
|
||||
"required_inputs": ["approved_evidence_matrix"],
|
||||
"expected_outputs": ["section_scaffolds", "asset_specs"],
|
||||
"acceptance_criteria": ["All production jobs have succeeded or been retried."],
|
||||
"requires_human_approval": False,
|
||||
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
|
||||
"parts": [],
|
||||
},
|
||||
{
|
||||
"stable_key": "draft-assembly",
|
||||
"display_name": "Draft assembly",
|
||||
"description": "Assemble a draft from the approved plan and production artifacts.",
|
||||
"position": 5,
|
||||
"owner_role": Role.EDITOR,
|
||||
"runner_profile_key": "draft-assembly-agent-v1",
|
||||
"required_inputs": ["section_scaffolds", "approved_assets"],
|
||||
"expected_outputs": ["article_draft"],
|
||||
"acceptance_criteria": ["Draft is assembled for review."],
|
||||
"requires_human_approval": False,
|
||||
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
|
||||
"parts": [],
|
||||
},
|
||||
{
|
||||
"stable_key": "seo-language-review",
|
||||
"display_name": "SEO and language review",
|
||||
"description": "Run automated quality reviews and resolve required suggestions.",
|
||||
"position": 6,
|
||||
"owner_role": Role.EDITOR,
|
||||
"runner_profile_key": "review-agent-v1",
|
||||
"required_inputs": ["article_draft"],
|
||||
"expected_outputs": ["resolved_review_suggestions"],
|
||||
"acceptance_criteria": ["Required SEO and language suggestions are resolved."],
|
||||
"requires_human_approval": True,
|
||||
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
|
||||
"parts": [],
|
||||
},
|
||||
{
|
||||
"stable_key": "final-publishing",
|
||||
"display_name": "Final approval and publishing",
|
||||
"description": "Approve final draft, dry-run the bundle, and create the publish commit.",
|
||||
"position": 7,
|
||||
"owner_role": Role.EDITOR,
|
||||
"runner_profile_key": "publishing-agent-v1",
|
||||
"required_inputs": ["resolved_review_suggestions", "approved_assets"],
|
||||
"expected_outputs": ["publish_commit"],
|
||||
"acceptance_criteria": ["Publish dry-run passes and commit is created."],
|
||||
"requires_human_approval": True,
|
||||
"retry_policy": {"max_attempts": 1, "backoff_seconds": 0},
|
||||
"parts": [],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _repository_url_for_seed() -> str:
|
||||
|
||||
@@ -45,6 +45,8 @@ from .models import (
|
||||
WorkflowTemplateCreateRequest,
|
||||
WorkflowTemplateListResponse,
|
||||
WorkflowTemplateResponse,
|
||||
WorkflowTemplateSnapshot,
|
||||
WorkflowTemplateStageSnapshot,
|
||||
WorkflowTemplateSummary,
|
||||
WorkflowTemplateUpdateRequest,
|
||||
AssetGenerateSpecsResponse,
|
||||
@@ -158,6 +160,8 @@ __all__ = [
|
||||
"WorkflowTemplateCreateRequest",
|
||||
"WorkflowTemplateListResponse",
|
||||
"WorkflowTemplateResponse",
|
||||
"WorkflowTemplateSnapshot",
|
||||
"WorkflowTemplateStageSnapshot",
|
||||
"WorkflowTemplateStatus",
|
||||
"WorkflowTemplateSummary",
|
||||
"WorkflowTemplateUpdateRequest",
|
||||
|
||||
@@ -273,8 +273,27 @@ class WorkflowTemplateAuditEventListResponse(ContractModel):
|
||||
events: list[WorkflowTemplateAuditEventSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WorkflowTemplateStageSnapshot(ContractModel):
|
||||
id: UUID
|
||||
stable_key: str = Field(min_length=1)
|
||||
display_name: str = Field(min_length=1)
|
||||
position: int = Field(ge=1)
|
||||
owner_role: Role
|
||||
runner_profile_key: str = Field(min_length=1)
|
||||
requires_human_approval: bool = False
|
||||
|
||||
|
||||
class WorkflowTemplateSnapshot(ContractModel):
|
||||
id: UUID
|
||||
name: str = Field(min_length=1)
|
||||
slug: str = Field(min_length=1)
|
||||
version: int = Field(ge=1)
|
||||
stage_summary: list[WorkflowTemplateStageSnapshot] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ArticleCreateRequest(ContractModel):
|
||||
target_site_id: UUID
|
||||
workflow_template_id: UUID | None = None
|
||||
brief_description: str = Field(min_length=1)
|
||||
working_title: str | None = None
|
||||
language: str = Field(default="en", min_length=2)
|
||||
@@ -286,6 +305,8 @@ class ArticleCreateRequest(ContractModel):
|
||||
class ArticleSummary(ContractModel):
|
||||
id: UUID
|
||||
target_site_id: UUID
|
||||
workflow_template_id: UUID | None = None
|
||||
workflow_template_version: int | None = Field(default=None, ge=1)
|
||||
status: ArticleWorkflowStatus
|
||||
publishing_status: PublishingStatus
|
||||
brief_description: str
|
||||
@@ -855,6 +876,7 @@ class AssetUploadResponse(ContractModel):
|
||||
class ArticleDetailResponse(ContractModel):
|
||||
article: ArticleSummary
|
||||
target_site: TargetSiteConfig | None = None
|
||||
workflow_template_snapshot: WorkflowTemplateSnapshot | None = None
|
||||
workflow_events: list[WorkflowEventSummary] = Field(default_factory=list)
|
||||
boundary_questions: list[BoundaryQuestionSummary] = Field(default_factory=list)
|
||||
plan: PlanSummary | None = None
|
||||
|
||||
@@ -121,6 +121,8 @@ from .models import (
|
||||
WorkflowTemplateCreateRequest,
|
||||
WorkflowTemplateListResponse,
|
||||
WorkflowTemplateResponse,
|
||||
WorkflowTemplateSnapshot,
|
||||
WorkflowTemplateStageSnapshot,
|
||||
WorkflowTemplateSummary,
|
||||
WorkflowTemplateUpdateRequest,
|
||||
TargetSiteConfigUpdateRequest,
|
||||
@@ -176,6 +178,8 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
||||
WorkflowStageResponse,
|
||||
WorkflowTemplateAuditEventSummary,
|
||||
WorkflowTemplateAuditEventListResponse,
|
||||
WorkflowTemplateStageSnapshot,
|
||||
WorkflowTemplateSnapshot,
|
||||
ArticleCreateRequest,
|
||||
ArticleSummary,
|
||||
ArticleCreateResponse,
|
||||
|
||||
@@ -47,6 +47,7 @@ from src.domain.contracts import (
|
||||
WorkflowStagePart,
|
||||
WorkflowStageSummary,
|
||||
WorkflowTemplateAuditEventSummary,
|
||||
WorkflowTemplateSnapshot,
|
||||
WorkflowTemplateStatus,
|
||||
WorkflowTemplateSummary,
|
||||
)
|
||||
@@ -442,6 +443,9 @@ class ArticlesRepository:
|
||||
self,
|
||||
*,
|
||||
target_site_id: UUID,
|
||||
workflow_template_id: UUID | None,
|
||||
workflow_template_version: int | None,
|
||||
workflow_template_snapshot: JsonObject | None,
|
||||
status: ArticleWorkflowStatus,
|
||||
publishing_status: PublishingStatus,
|
||||
brief_description: str,
|
||||
@@ -455,10 +459,14 @@ class ArticlesRepository:
|
||||
) -> ArticleSummary:
|
||||
article_id = uuid4()
|
||||
placeholder = self._repository.placeholder()
|
||||
json_cast = self._repository.json_cast()
|
||||
sql = f"""
|
||||
INSERT INTO articles (
|
||||
id,
|
||||
target_site_id,
|
||||
workflow_template_id,
|
||||
workflow_template_version,
|
||||
workflow_template_snapshot,
|
||||
status,
|
||||
publishing_status,
|
||||
brief_description,
|
||||
@@ -477,6 +485,9 @@ class ArticlesRepository:
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder}{json_cast},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
@@ -491,6 +502,13 @@ class ArticlesRepository:
|
||||
(
|
||||
str(article_id),
|
||||
str(target_site_id),
|
||||
_uuid_value(workflow_template_id),
|
||||
workflow_template_version,
|
||||
(
|
||||
_json_value(workflow_template_snapshot)
|
||||
if workflow_template_snapshot is not None
|
||||
else None
|
||||
),
|
||||
status.value,
|
||||
publishing_status.value,
|
||||
brief_description,
|
||||
@@ -513,6 +531,8 @@ class ArticlesRepository:
|
||||
SELECT
|
||||
id,
|
||||
target_site_id,
|
||||
workflow_template_id,
|
||||
workflow_template_version,
|
||||
status,
|
||||
publishing_status,
|
||||
brief_description,
|
||||
@@ -538,6 +558,8 @@ class ArticlesRepository:
|
||||
SELECT
|
||||
id,
|
||||
target_site_id,
|
||||
workflow_template_id,
|
||||
workflow_template_version,
|
||||
status,
|
||||
publishing_status,
|
||||
brief_description,
|
||||
@@ -558,6 +580,28 @@ class ArticlesRepository:
|
||||
raise LookupError(f"Article not found: {article_id}")
|
||||
return _article_summary_from_row(row)
|
||||
|
||||
def get_workflow_template_snapshot(
|
||||
self,
|
||||
article_id: UUID,
|
||||
) -> WorkflowTemplateSnapshot | None:
|
||||
placeholder = self._repository.placeholder()
|
||||
with self._repository.connection() as connection:
|
||||
row = connection.execute(
|
||||
f"""
|
||||
SELECT workflow_template_snapshot
|
||||
FROM articles
|
||||
WHERE id = {placeholder}
|
||||
""",
|
||||
(str(article_id),),
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
raise LookupError(f"Article not found: {article_id}")
|
||||
snapshot = _json_from_row(row, "workflow_template_snapshot")
|
||||
if snapshot is None:
|
||||
return None
|
||||
return WorkflowTemplateSnapshot.model_validate(snapshot)
|
||||
|
||||
def update_status(
|
||||
self,
|
||||
*,
|
||||
@@ -3860,6 +3904,8 @@ def _article_summary_from_row(row: Any) -> ArticleSummary:
|
||||
return ArticleSummary(
|
||||
id=_row_value(row, "id"),
|
||||
target_site_id=_row_value(row, "target_site_id"),
|
||||
workflow_template_id=_row_value(row, "workflow_template_id"),
|
||||
workflow_template_version=_row_value(row, "workflow_template_version"),
|
||||
status=_row_value(row, "status"),
|
||||
publishing_status=_row_value(row, "publishing_status"),
|
||||
brief_description=_row_value(row, "brief_description"),
|
||||
@@ -4218,6 +4264,7 @@ def _plain_row(row: Any) -> dict[str, Any]:
|
||||
"retry_policy",
|
||||
"parts",
|
||||
"payload",
|
||||
"workflow_template_snapshot",
|
||||
):
|
||||
if key in result:
|
||||
result[key] = _json_decode(result[key])
|
||||
|
||||
@@ -148,6 +148,9 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
target_site_id UUID NOT NULL REFERENCES target_sites(id),
|
||||
workflow_template_id UUID REFERENCES workflow_templates(id),
|
||||
workflow_template_version INTEGER,
|
||||
workflow_template_snapshot JSONB,
|
||||
status TEXT NOT NULL CHECK (status IN ({ARTICLE_STATUS_VALUES})),
|
||||
publishing_status TEXT NOT NULL CHECK (
|
||||
publishing_status IN ({PUBLISHING_STATUS_VALUES})
|
||||
@@ -162,6 +165,9 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
"ALTER TABLE articles ADD COLUMN IF NOT EXISTS workflow_template_id UUID REFERENCES workflow_templates(id)",
|
||||
"ALTER TABLE articles ADD COLUMN IF NOT EXISTS workflow_template_version INTEGER",
|
||||
"ALTER TABLE articles ADD COLUMN IF NOT EXISTS workflow_template_snapshot JSONB",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS boundary_questions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -473,6 +479,10 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
||||
ON articles (assigned_editor_id, updated_at DESC)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_workflow_template
|
||||
ON articles (workflow_template_id, workflow_template_version)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_jobs_queue
|
||||
ON agent_jobs (status, queued_at)
|
||||
""",
|
||||
@@ -610,6 +620,9 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
id TEXT PRIMARY KEY,
|
||||
target_site_id TEXT NOT NULL,
|
||||
workflow_template_id TEXT,
|
||||
workflow_template_version INTEGER,
|
||||
workflow_template_snapshot TEXT,
|
||||
status TEXT NOT NULL,
|
||||
publishing_status TEXT NOT NULL,
|
||||
brief_description TEXT NOT NULL,
|
||||
@@ -900,6 +913,10 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
||||
ON articles (assigned_editor_id, updated_at)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_workflow_template
|
||||
ON articles (workflow_template_id, workflow_template_version)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_jobs_queue
|
||||
ON agent_jobs (status, queued_at)
|
||||
""",
|
||||
|
||||
@@ -45,6 +45,11 @@ def post_article(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid target_site_id",
|
||||
) from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(error),
|
||||
) from error
|
||||
|
||||
|
||||
@router.get("/articles", response_model=ArticleListResponse)
|
||||
|
||||
@@ -316,8 +316,35 @@ class AuthAuthorizationPublicApiTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
article_id = article_response.json()["article"]["id"]
|
||||
questions_response = self.client.post(
|
||||
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, questions_response.status_code, questions_response.text)
|
||||
for question in questions_response.json()["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_id = plan_response.json()["plan"]["id"]
|
||||
|
||||
review_response = self.client.post(
|
||||
f"/api/articles/{article_id}/plans/00000000-0000-0000-0000-000000000123/approve",
|
||||
f"/api/articles/{article_id}/plans/{plan_id}/approve",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
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_ADMIN_EMAIL = "admin@example.com"
|
||||
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||
|
||||
|
||||
class WorkflowTemplateBindingPublicApiTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'workflow-template-binding.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()
|
||||
self.tmp_dir.cleanup()
|
||||
|
||||
def test_article_creation_stores_active_workflow_template_snapshot(self) -> None:
|
||||
workflow = self._create_active_workflow("article-binding-active")
|
||||
target_site = self._first_target_site()
|
||||
|
||||
create_response = self.client.post(
|
||||
"/api/articles",
|
||||
headers=self._editor_headers(),
|
||||
json={
|
||||
"target_site_id": target_site["id"],
|
||||
"workflow_template_id": workflow["id"],
|
||||
"brief_description": (
|
||||
"Create an article that is governed by an Admin-managed "
|
||||
f"workflow template {uuid4()}."
|
||||
),
|
||||
"working_title": "Workflow-bound article",
|
||||
"content_type": "longform_guide",
|
||||
"primary_keyword": "workflow template binding",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||
article = create_response.json()["article"]
|
||||
self.assertEqual(workflow["id"], article["workflow_template_id"])
|
||||
self.assertEqual(workflow["version"], article["workflow_template_version"])
|
||||
|
||||
archive_response = self.client.post(
|
||||
f"/api/admin/workflows/{workflow['id']}/archive",
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
self.assertEqual(200, archive_response.status_code, archive_response.text)
|
||||
|
||||
detail_response = self.client.get(
|
||||
f"/api/articles/{article['id']}",
|
||||
headers=self._editor_headers(),
|
||||
)
|
||||
self.assertEqual(200, detail_response.status_code, detail_response.text)
|
||||
detail = detail_response.json()
|
||||
|
||||
expected_stage_summary = [
|
||||
{
|
||||
"id": stage["id"],
|
||||
"stable_key": stage["stable_key"],
|
||||
"display_name": stage["display_name"],
|
||||
"position": stage["position"],
|
||||
"owner_role": stage["owner_role"],
|
||||
"runner_profile_key": stage["runner_profile_key"],
|
||||
"requires_human_approval": stage["requires_human_approval"],
|
||||
}
|
||||
for stage in workflow["stages"]
|
||||
]
|
||||
|
||||
self.assertEqual(
|
||||
{
|
||||
"id": workflow["id"],
|
||||
"name": workflow["name"],
|
||||
"slug": workflow["slug"],
|
||||
"version": workflow["version"],
|
||||
"stage_summary": expected_stage_summary,
|
||||
},
|
||||
detail["workflow_template_snapshot"],
|
||||
)
|
||||
|
||||
def test_article_creation_rejects_archived_workflow_template_reference(self) -> None:
|
||||
workflow = self._create_active_workflow("article-binding-archived")
|
||||
archive_response = self.client.post(
|
||||
f"/api/admin/workflows/{workflow['id']}/archive",
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
self.assertEqual(200, archive_response.status_code, archive_response.text)
|
||||
|
||||
response = self._create_article_with_workflow_template(workflow["id"])
|
||||
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
self.assertEqual("Workflow template must be active", response.json()["detail"])
|
||||
|
||||
def test_article_creation_rejects_missing_workflow_template_reference(self) -> None:
|
||||
response = self._create_article_with_workflow_template(str(uuid4()))
|
||||
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
self.assertEqual("Invalid workflow_template_id", response.json()["detail"])
|
||||
|
||||
def test_seeded_active_workflow_template_is_available_for_editor_articles(self) -> None:
|
||||
workflows_response = self.client.get(
|
||||
"/api/admin/workflows",
|
||||
headers=self._editor_headers(),
|
||||
)
|
||||
self.assertEqual(200, workflows_response.status_code, workflows_response.text)
|
||||
seeded_workflow = next(
|
||||
workflow
|
||||
for workflow in workflows_response.json()["workflows"]
|
||||
if workflow["slug"] == "demo-article-production"
|
||||
)
|
||||
self.assertEqual("ACTIVE", seeded_workflow["status"])
|
||||
self.assertGreaterEqual(len(seeded_workflow["stages"]), 7)
|
||||
|
||||
create_response = self._create_article_with_workflow_template(
|
||||
seeded_workflow["id"]
|
||||
)
|
||||
|
||||
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||
article = create_response.json()["article"]
|
||||
self.assertEqual(seeded_workflow["id"], article["workflow_template_id"])
|
||||
self.assertEqual(
|
||||
seeded_workflow["version"],
|
||||
article["workflow_template_version"],
|
||||
)
|
||||
|
||||
def _create_article_with_workflow_template(self, workflow_template_id: str) -> Any:
|
||||
target_site = self._first_target_site()
|
||||
return self.client.post(
|
||||
"/api/articles",
|
||||
headers=self._editor_headers(),
|
||||
json={
|
||||
"target_site_id": target_site["id"],
|
||||
"workflow_template_id": workflow_template_id,
|
||||
"brief_description": (
|
||||
"Create an article using the selected workflow template "
|
||||
f"{uuid4()}."
|
||||
),
|
||||
"content_type": "longform_guide",
|
||||
"primary_keyword": "workflow binding guardrail",
|
||||
},
|
||||
)
|
||||
|
||||
def _create_active_workflow(self, slug_prefix: str) -> dict[str, Any]:
|
||||
create_response = self.client.post(
|
||||
"/api/admin/workflows",
|
||||
headers=self._admin_headers(),
|
||||
json={
|
||||
"name": "Article production workflow",
|
||||
"slug": f"{slug_prefix}-{uuid4()}",
|
||||
"description": "Reusable editorial workflow for article production.",
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||
workflow = create_response.json()["workflow"]
|
||||
workflow_id = workflow["id"]
|
||||
|
||||
for stage_payload in (
|
||||
self._intake_stage_payload(),
|
||||
self._draft_stage_payload(),
|
||||
):
|
||||
stage_response = self.client.post(
|
||||
f"/api/admin/workflows/{workflow_id}/stages",
|
||||
headers=self._admin_headers(),
|
||||
json=stage_payload,
|
||||
)
|
||||
self.assertEqual(201, stage_response.status_code, stage_response.text)
|
||||
|
||||
activate_response = self.client.post(
|
||||
f"/api/admin/workflows/{workflow_id}/activate",
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
self.assertEqual(200, activate_response.status_code, activate_response.text)
|
||||
workflow = activate_response.json()["workflow"]
|
||||
self.assertEqual("ACTIVE", workflow["status"])
|
||||
self.assertEqual(2, workflow["version"])
|
||||
return workflow
|
||||
|
||||
def _first_target_site(self) -> dict[str, Any]:
|
||||
response = self.client.get("/api/sites", headers=self._editor_headers())
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
return response.json()[0]["site"]
|
||||
|
||||
def _intake_stage_payload(self) -> dict[str, object]:
|
||||
return {
|
||||
"stable_key": "intake-boundary-questions",
|
||||
"display_name": "Boundary question intake",
|
||||
"description": "Collect required editorial context before planning.",
|
||||
"position": 1,
|
||||
"owner_role": "EDITOR",
|
||||
"runner_profile_key": "boundary-question-agent-v1",
|
||||
"required_inputs": [
|
||||
"article_brief",
|
||||
"target_site",
|
||||
],
|
||||
"expected_outputs": [
|
||||
"answered_boundary_questions",
|
||||
],
|
||||
"acceptance_criteria": [
|
||||
"All required boundary questions are answered.",
|
||||
],
|
||||
"requires_human_approval": True,
|
||||
"retry_policy": {
|
||||
"max_attempts": 1,
|
||||
"backoff_seconds": 0,
|
||||
},
|
||||
"parts": [
|
||||
{
|
||||
"key": "required-context-checklist",
|
||||
"type": "checklist",
|
||||
"title": "Required context checklist",
|
||||
"payload": {
|
||||
"prompt": "Identify missing audience and source constraints.",
|
||||
},
|
||||
"acceptance_criteria": [
|
||||
"Missing required context is listed explicitly.",
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def _draft_stage_payload(self) -> dict[str, object]:
|
||||
return {
|
||||
"stable_key": "draft-assembly",
|
||||
"display_name": "Draft assembly",
|
||||
"description": "Generate a first draft from the approved plan.",
|
||||
"position": 2,
|
||||
"owner_role": "EDITOR",
|
||||
"runner_profile_key": "draft-writer-v1",
|
||||
"required_inputs": [
|
||||
"approved_plan",
|
||||
],
|
||||
"expected_outputs": [
|
||||
"article_draft",
|
||||
],
|
||||
"acceptance_criteria": [
|
||||
"Draft follows the approved plan.",
|
||||
],
|
||||
"requires_human_approval": False,
|
||||
"retry_policy": {
|
||||
"max_attempts": 2,
|
||||
"backoff_seconds": 60,
|
||||
},
|
||||
"parts": [
|
||||
{
|
||||
"key": "draft-outline",
|
||||
"type": "outline",
|
||||
"title": "Draft outline",
|
||||
"payload": {
|
||||
"prompt": "Build a concise outline before drafting.",
|
||||
},
|
||||
"acceptance_criteria": [
|
||||
"Every section has a purpose.",
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def _admin_headers(self) -> dict[str, str]:
|
||||
return {DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}
|
||||
|
||||
def _editor_headers(self) -> dict[str, str]:
|
||||
return {DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user