Compare commits

..
10 Commits
132 changed files with 18786 additions and 135 deletions
+1
View File
@@ -2,6 +2,7 @@
.DS_Store .DS_Store
node_modules/ node_modules/
.next/ .next/
*.tsbuildinfo
__pycache__/ __pycache__/
*.pyc *.pyc
.pytest_cache/ .pytest_cache/
+7
View File
@@ -0,0 +1,7 @@
supportedArchitectures.os[]=current
supportedArchitectures.os[]=linux
supportedArchitectures.cpu[]=current
supportedArchitectures.cpu[]=x64
supportedArchitectures.cpu[]=arm64
supportedArchitectures.libc[]=current
supportedArchitectures.libc[]=glibc
+12 -1
View File
@@ -1,4 +1,9 @@
.PHONY: dev down smoke PYTHON ?= python3
.PHONY: contracts dev down migrate seed smoke
contracts:
$(PYTHON) scripts/generate_openapi_contracts.py
dev: dev:
docker compose up --build docker compose up --build
@@ -6,5 +11,11 @@ dev:
down: down:
docker compose down --remove-orphans docker compose down --remove-orphans
migrate:
alembic upgrade head
seed:
PYTHONPATH=apps/backend $(PYTHON) -m src.infrastructure.seed_data
smoke: smoke:
bash tests/smoke/public-health.sh bash tests/smoke/public-health.sh
+41
View File
@@ -0,0 +1,41 @@
[alembic]
script_location = apps/backend/alembic
prepend_sys_path = apps/backend
path_separator = os
sqlalchemy.url = postgresql+psycopg://pipeline:pipeline_local@localhost:5432/pipeline
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+2
View File
@@ -8,6 +8,8 @@ WORKDIR /app
COPY apps/backend/requirements.txt ./requirements.txt COPY apps/backend/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY alembic.ini ./alembic.ini
COPY apps/backend/alembic ./apps/backend/alembic
COPY apps/backend/src ./src COPY apps/backend/src ./src
EXPOSE 8000 EXPOSE 8000
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import os
import sys
from logging.config import fileConfig
from pathlib import Path
from alembic import context
from sqlalchemy import engine_from_config, pool
BACKEND_ROOT = Path(__file__).resolve().parents[1]
for import_root in (BACKEND_ROOT, BACKEND_ROOT.parents[1]):
if (import_root / "src").exists():
sys.path.insert(0, str(import_root))
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = None
def _database_url() -> str:
configured = os.environ.get("DATABASE_URL") or os.environ.get("POSTGRES_DSN")
if not configured:
return config.get_main_option("sqlalchemy.url")
if configured.startswith("postgresql://"):
return "postgresql+psycopg://" + configured.removeprefix("postgresql://")
if configured.startswith("postgres://"):
return "postgresql+psycopg://" + configured.removeprefix("postgres://")
return configured
def run_migrations_offline() -> None:
context.configure(
url=_database_url(),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = _database_url()
connectable = engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,45 @@
"""initial postgres schema
Revision ID: 202605210003
Revises:
Create Date: 2026-05-21 00:03:00.000000
"""
from __future__ import annotations
from alembic import op
from src.infrastructure.schema import POSTGRES_SCHEMA_STATEMENTS
revision = "202605210003"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
for statement in POSTGRES_SCHEMA_STATEMENTS:
op.execute(statement)
def downgrade() -> None:
for table_name in (
"prompt_versions",
"publish_commits",
"research_run_manifests",
"agent_jobs",
"workflow_events",
"assets",
"article_drafts",
"claims",
"evidence_items",
"plan_sections",
"article_plans",
"boundary_questions",
"articles",
"script_config_versions",
"target_sites",
"users",
):
op.execute(f"DROP TABLE IF EXISTS {table_name} CASCADE")
+3
View File
@@ -1,5 +1,8 @@
fastapi==0.115.6 fastapi==0.115.6
httpx==0.28.1
uvicorn[standard]==0.34.0 uvicorn[standard]==0.34.0
alembic==1.14.0
SQLAlchemy==2.0.36
psycopg[binary]==3.2.3 psycopg[binary]==3.2.3
redis==5.2.1 redis==5.2.1
boto3==1.35.90 boto3==1.35.90
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from pydantic import ValidationError
from src.domain.contracts import (
AgentJobErrorCategory,
AgentJobListResponse,
AgentJobOutput,
AgentJobResponse,
AgentJobStatus,
AgentJobTestCodexRequest,
AgentJobType,
)
VALID_FAKE_AGENT_PROFILE = "fake-codex"
INVALID_SCHEMA_FAKE_AGENT_PROFILE = "fake-invalid-schema"
def create_test_codex_job(
repository: object,
request: AgentJobTestCodexRequest,
) -> AgentJobResponse:
agent_profile = (
INVALID_SCHEMA_FAKE_AGENT_PROFILE
if request.fake_result == "invalid_schema"
else VALID_FAKE_AGENT_PROFILE
)
job = repository.agent_jobs.create(
article_id=None,
parent_job_id=None,
attempt=1,
job_type=AgentJobType.TEST_CODEX,
agent_profile=agent_profile,
status=AgentJobStatus.QUEUED,
input_files=[{"path": "inputs/job.json"}],
queued_at=_now(),
)
return AgentJobResponse(job=job)
def list_agent_jobs(repository: object) -> AgentJobListResponse:
return AgentJobListResponse(jobs=repository.agent_jobs.list())
def get_agent_job(repository: object, job_id: UUID) -> AgentJobResponse:
return AgentJobResponse(job=repository.agent_jobs.get(job_id))
def retry_agent_job(repository: object, job_id: UUID) -> AgentJobResponse:
parent = repository.agent_jobs.get(job_id)
retry = repository.agent_jobs.create(
article_id=parent.article_id,
parent_job_id=parent.id,
attempt=parent.attempt + 1,
job_type=parent.job_type,
agent_profile=parent.agent_profile,
status=AgentJobStatus.QUEUED,
input_files=[file_ref.model_dump(mode="json") for file_ref in parent.input_files],
queued_at=_now(),
)
return AgentJobResponse(job=retry)
def cancel_agent_job(repository: object, job_id: UUID) -> AgentJobResponse:
return AgentJobResponse(job=repository.agent_jobs.cancel(job_id=job_id, finished_at=_now()))
def claim_next_agent_job(repository: object) -> AgentJobResponse | None:
job = repository.agent_jobs.claim_next_queued(started_at=_now())
if job is None:
return None
return AgentJobResponse(job=job)
def complete_agent_job(
repository: object,
*,
job_id: UUID,
workspace_path: str | None,
stdout: str,
stderr: str,
exit_code: int | None,
duration_ms: int | None,
output: dict[str, Any],
) -> AgentJobResponse:
try:
validated_output = AgentJobOutput.model_validate(output)
except ValidationError as error:
job = repository.agent_jobs.complete(
job_id=job_id,
status=AgentJobStatus.FAILED,
workspace_path=workspace_path,
output_files=[],
error_category=AgentJobErrorCategory.FAILED_SCHEMA_VALIDATION,
error_message=str(error),
stdout=stdout,
stderr=stderr,
exit_code=exit_code,
duration_ms=duration_ms,
finished_at=_now(),
)
return AgentJobResponse(job=job)
job = repository.agent_jobs.complete(
job_id=job_id,
status=_normalize_completion_status(
status=validated_output.status,
exit_code=exit_code,
error_category=validated_output.error_category,
),
workspace_path=workspace_path,
output_files=[
file_ref.model_dump(mode="json") for file_ref in validated_output.output_files
],
error_category=_normalize_error_category(
initial=validated_output.error_category,
status=validated_output.status,
exit_code=exit_code,
),
error_message=validated_output.error_message,
stdout=stdout,
stderr=stderr,
exit_code=exit_code,
duration_ms=duration_ms,
finished_at=_now(),
)
return AgentJobResponse(job=job)
def _now() -> datetime:
return datetime.now(UTC)
def _normalize_completion_status(
*,
status: AgentJobStatus,
exit_code: int | None,
error_category: AgentJobErrorCategory | None,
) -> AgentJobStatus:
if status == AgentJobStatus.SUCCEEDED and exit_code not in (0, None):
return AgentJobStatus.FAILED
if error_category == AgentJobErrorCategory.CLI_EXIT_CODE_FAILURE and exit_code in (
None,
0,
):
return AgentJobStatus.SUCCEEDED
return status
def _normalize_error_category(
*,
initial: AgentJobErrorCategory | None,
status: AgentJobStatus,
exit_code: int | None,
) -> AgentJobErrorCategory | None:
if initial is not None:
return initial
if status == AgentJobStatus.SUCCEEDED and exit_code not in (0, None):
return AgentJobErrorCategory.CLI_EXIT_CODE_FAILURE
return None
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import UUID
from src.domain.contracts import (
ArticleCreateRequest,
ArticleCreateResponse,
ArticleDetailResponse,
ArticleListResponse,
ArticleWorkflowStatus,
CurrentUser,
PublishingStatus,
)
def create_article(
repository: object,
request: ArticleCreateRequest,
current_user: CurrentUser,
) -> ArticleCreateResponse:
repository.target_sites.get_by_id(request.target_site_id)
now = _now()
summary = repository.articles.create(
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 or current_user.id,
created_at=now,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=summary.id,
event_type="ARTICLE_CREATED",
from_status=None,
to_status=ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
actor_user_id=current_user.id,
payload={"brief_description_length": len(request.brief_description)},
created_at=now,
)
return ArticleCreateResponse(article=summary)
def list_articles(repository: object) -> ArticleListResponse:
return ArticleListResponse(articles=repository.articles.list())
def get_article_detail(
repository: object,
article_id: UUID,
) -> ArticleDetailResponse:
article = repository.articles.get(article_id)
target_site = repository.target_sites.get_by_id(article.target_site_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)
research_manifests = repository.research_manifests.list_for_article(article_id)
return ArticleDetailResponse(
article=article,
target_site=target_site,
workflow_events=workflow_events,
boundary_questions=boundary_questions,
plan=plans[-1] if plans else None,
research_manifests=research_manifests,
)
def _now() -> datetime:
return datetime.now(UTC)
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from src.domain.contracts import CurrentUser
def get_current_user_by_email(repository: object, email: str) -> CurrentUser:
user = repository.users.get_by_email(email)
return CurrentUser(
id=user.id,
email=email,
display_name=user.display_name,
role=user.role,
)
@@ -0,0 +1,265 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import UUID
from src.domain.contracts import (
AgentJobErrorCategory,
AgentJobListResponse,
AgentJobStatus,
AgentJobType,
ArticleSummary,
ArticleWorkflowStatus,
BoundaryQuestionListResponse,
BoundaryQuestionResponse,
BoundaryQuestionUpdateRequest,
CurrentUser,
)
BOUNDARY_QUESTION_FIXTURE: tuple[dict[str, object], ...] = (
{
"category": "audience",
"label": "Audience",
"is_required": True,
},
{
"category": "purpose",
"label": "Purpose",
"is_required": True,
},
{
"category": "reader_outcome",
"label": "Reader outcome",
"is_required": True,
},
{
"category": "depth",
"label": "Depth",
"is_required": True,
},
{
"category": "tone",
"label": "Tone",
"is_required": True,
},
{
"category": "excluded_topics",
"label": "Excluded topics",
"is_required": True,
},
{
"category": "primary_keyword",
"label": "Primary keyword",
"is_required": True,
},
{
"category": "competitor_angle",
"label": "Competitor angle",
"is_required": True,
},
{
"category": "evidence_standard",
"label": "Evidence standard",
"is_required": True,
},
{
"category": "visual_expectations",
"label": "Visual expectations",
"is_required": False,
},
)
def generate_boundary_questions(
repository: object,
*,
article_id: UUID,
current_user: CurrentUser,
) -> BoundaryQuestionListResponse:
article = repository.articles.get(article_id)
target_site = repository.target_sites.get_by_id(article.target_site_id)
now = _now()
previous_status = article.status
job = repository.agent_jobs.create(
article_id=article_id,
parent_job_id=None,
attempt=1,
job_type=AgentJobType.BOUNDARY_QUESTIONS,
agent_profile="fake-boundary-questions",
status=AgentJobStatus.QUEUED,
input_files=[
{
"path": "inputs/article-brief.json",
"content_hash": None,
},
{
"path": "inputs/target-site-config.json",
"content_hash": None,
},
],
queued_at=now,
)
job = repository.agent_jobs.complete(
job_id=job.id,
status=AgentJobStatus.SUCCEEDED,
workspace_path=None,
output_files=[{"path": "outputs/boundary-questions.json"}],
error_category=None,
error_message=None,
stdout="fake boundary question fixture generated\n",
stderr="",
exit_code=0,
duration_ms=0,
finished_at=now,
)
questions = repository.boundary_questions.replace_for_article(
article_id=article_id,
questions=_build_questions(article=article, target_site=target_site),
created_at=now,
)
article = repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article.id,
event_type="BOUNDARY_QUESTIONS_GENERATED",
from_status=previous_status,
to_status=ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED,
actor_user_id=current_user.id,
payload={"agent_job_id": str(job.id), "question_count": len(questions)},
created_at=now,
)
return BoundaryQuestionListResponse(questions=questions, job=job)
def list_boundary_questions(
repository: object,
*,
article_id: UUID,
) -> BoundaryQuestionListResponse:
repository.articles.get(article_id)
return BoundaryQuestionListResponse(
questions=repository.boundary_questions.list_for_article(article_id),
)
def update_boundary_question(
repository: object,
*,
article_id: UUID,
question_id: UUID,
request: BoundaryQuestionUpdateRequest,
) -> BoundaryQuestionResponse:
repository.articles.get(article_id)
answer = request.answer.strip() if request.answer is not None else None
question = repository.boundary_questions.update_answer(
article_id=article_id,
question_id=question_id,
answer=answer or None,
updated_at=_now(),
)
return BoundaryQuestionResponse(question=question)
def submit_boundary_questions(
repository: object,
*,
article_id: UUID,
current_user: CurrentUser,
) -> ArticleSummary:
article = repository.articles.get(article_id)
questions = repository.boundary_questions.list_for_article(article_id)
if not questions:
raise ValueError("Boundary questions must be generated before submission")
missing_required = [
question.category
for question in questions
if question.is_required and not (question.answer or "").strip()
]
if missing_required:
raise ValueError(
"All required boundary questions must be answered: "
+ ", ".join(missing_required)
)
now = _now()
updated_article = repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="BOUNDARY_ANSWERS_SUBMITTED",
from_status=article.status,
to_status=ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
actor_user_id=current_user.id,
payload={"required_answer_count": len(questions) - len(missing_required)},
created_at=now,
)
return updated_article
def create_plan_generation_job(
repository: object,
*,
article_id: UUID,
) -> AgentJobListResponse:
article = repository.articles.get(article_id)
if article.status != ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED:
raise PermissionError("Boundary answers must be submitted before plan generation")
job = repository.agent_jobs.create(
article_id=article_id,
parent_job_id=None,
attempt=1,
job_type=AgentJobType.PLAN_GENERATION,
agent_profile="fake-plan-generation",
status=AgentJobStatus.QUEUED,
input_files=[{"path": "inputs/boundary-answers.json", "content_hash": None}],
queued_at=_now(),
)
return AgentJobListResponse(jobs=[job])
def _build_questions(*, article: ArticleSummary, target_site: object) -> list[dict[str, object]]:
title = article.working_title or article.primary_keyword or article.content_type
site_audience = getattr(target_site, "audience")
brand_voice = getattr(target_site, "brand_voice")
primary_keyword = article.primary_keyword or "the primary topic"
brief = article.brief_description
prompts = {
"audience": f"Who exactly is this article for on {site_audience}?",
"purpose": f"What business or editorial purpose should '{title}' serve?",
"reader_outcome": "What should the reader be able to decide or do after reading?",
"depth": f"How deep should coverage go for this {article.content_type} brief?",
"tone": f"What tone should adapt the site voice '{brand_voice}' for this brief?",
"excluded_topics": "Which topics, claims, or comparisons must be excluded?",
"primary_keyword": f"How should the keyword '{primary_keyword}' be prioritized?",
"competitor_angle": "Which competitor angle should the article address or avoid?",
"evidence_standard": "What evidence standard is required for claims in this article?",
"visual_expectations": "What diagrams, tables, or images would clarify the brief?",
}
return [
{
"sort_order": index,
"category": str(item["category"]),
"question": f"{item['label']}: {prompts[str(item['category'])]} Brief: {brief}",
"answer": None,
"is_required": bool(item["is_required"]),
}
for index, item in enumerate(BOUNDARY_QUESTION_FIXTURE, start=1)
]
def _now() -> datetime:
return datetime.now(UTC)
+330
View File
@@ -0,0 +1,330 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import UUID, uuid4
from src.domain.contracts import (
AgentJobListResponse,
AgentJobStatus,
AgentJobType,
ArticleWorkflowStatus,
CurrentUser,
PlanListResponse,
PlanResponse,
PlanReviewStatus,
PlanRevisionRequest,
PlanSummary,
PlanUpdateRequest,
ReviewActionResponse,
ReviewStatus,
ReviewSummary,
ReviewType,
)
def generate_plan(
repository: object,
*,
article_id: UUID,
current_user: CurrentUser,
) -> PlanResponse:
article = repository.articles.get(article_id)
if article.status not in {
ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
}:
raise PermissionError("Boundary answers must be submitted before plan generation")
target_site = repository.target_sites.get_by_id(article.target_site_id)
questions = repository.boundary_questions.list_for_article(article_id)
version = repository.article_plans.latest_version(article_id) + 1
now = _now()
plan = repository.article_plans.create_version(
article_id=article_id,
version=version,
status=PlanReviewStatus.PENDING_REVIEW,
created_at=now,
**_plan_fixture(article=article, target_site=target_site, questions=questions),
)
repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="PLAN_GENERATED",
from_status=article.status,
to_status=ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED,
actor_user_id=current_user.id,
payload={"plan_id": str(plan.id), "plan_version": plan.version},
created_at=now,
)
return PlanResponse(plan=plan)
def list_plans(repository: object, *, article_id: UUID) -> PlanListResponse:
repository.articles.get(article_id)
return PlanListResponse(plans=repository.article_plans.list_for_article(article_id))
def get_plan(repository: object, *, article_id: UUID, plan_id: UUID) -> PlanResponse:
repository.articles.get(article_id)
return PlanResponse(plan=repository.article_plans.get(article_id=article_id, plan_id=plan_id))
def edit_plan(
repository: object,
*,
article_id: UUID,
plan_id: UUID,
request: PlanUpdateRequest,
current_user: CurrentUser,
) -> PlanResponse:
original = repository.article_plans.get(article_id=article_id, plan_id=plan_id)
payload = _plan_payload_from_summary(original)
update = request.model_dump(exclude_unset=True)
payload.update(update)
if request.sections is not None:
payload["sections"] = [
section.model_dump(mode="json") for section in request.sections
]
now = _now()
plan = repository.article_plans.create_version(
article_id=article_id,
version=repository.article_plans.latest_version(article_id) + 1,
status=PlanReviewStatus.PENDING_REVIEW,
created_at=now,
**payload,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="PLAN_EDITED",
from_status=ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED,
to_status=ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED,
actor_user_id=current_user.id,
payload={
"previous_plan_id": str(original.id),
"new_plan_id": str(plan.id),
"plan_version": plan.version,
},
created_at=now,
)
return PlanResponse(plan=plan)
def request_plan_revision(
repository: object,
*,
article_id: UUID,
plan_id: UUID,
request: PlanRevisionRequest,
current_user: CurrentUser,
) -> ReviewActionResponse:
plan = repository.article_plans.update_status(
article_id=article_id,
plan_id=plan_id,
status=PlanReviewStatus.REVISION_REQUESTED,
)
now = _now()
repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="PLAN_REVISION_REQUESTED",
from_status=ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED,
to_status=ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
actor_user_id=current_user.id,
payload={"plan_id": str(plan.id), "plan_version": plan.version, "notes": request.notes},
created_at=now,
)
return _review_response(
article_id=article_id,
reviewer_id=current_user.id,
status=ReviewStatus.CHANGES_REQUESTED,
notes=request.notes,
)
def approve_plan(
repository: object,
*,
article_id: UUID,
plan_id: UUID,
current_user: CurrentUser,
) -> ReviewActionResponse:
plan = repository.article_plans.update_status(
article_id=article_id,
plan_id=plan_id,
status=PlanReviewStatus.APPROVED,
)
now = _now()
repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.RESEARCH_RUNNING,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="PLAN_APPROVED",
from_status=ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED,
to_status=ArticleWorkflowStatus.RESEARCH_RUNNING,
actor_user_id=current_user.id,
payload={"plan_id": str(plan.id), "plan_version": plan.version},
created_at=now,
)
return _review_response(
article_id=article_id,
reviewer_id=current_user.id,
status=ReviewStatus.APPROVED,
notes=f"Approved plan version {plan.version}",
)
def start_research(
repository: object,
*,
article_id: UUID,
) -> AgentJobListResponse:
article = repository.articles.get(article_id)
approved = [
plan
for plan in repository.article_plans.list_for_article(article_id)
if plan.status == PlanReviewStatus.APPROVED
]
if article.status != ArticleWorkflowStatus.RESEARCH_RUNNING or not approved:
raise PermissionError("Plan must be approved before research can start")
job = repository.agent_jobs.create(
article_id=article_id,
parent_job_id=None,
attempt=1,
job_type=AgentJobType.RESEARCH,
agent_profile="fake-research",
status=AgentJobStatus.QUEUED,
input_files=[{"path": "inputs/approved-plan.json", "content_hash": None}],
queued_at=_now(),
)
return AgentJobListResponse(jobs=[job])
def _plan_fixture(*, article: object, target_site: object, questions: list[object]) -> dict[str, object]:
keyword = article.primary_keyword or article.working_title or "content workflow"
audience = next(
(question.answer for question in questions if question.category == "audience" and question.answer),
target_site.audience,
)
return {
"title_options": [
f"{keyword}: practical implementation guide",
f"How to evaluate {keyword}",
],
"recommended_title": article.working_title or f"{keyword} guide",
"reader_persona": audience,
"search_intent": f"Evaluate and implement {keyword}",
"thesis": f"A strong {keyword} article needs clear scope, evidence, and review gates.",
"claims_to_prove": [
"Governed workflows reduce editorial risk",
"Plan approval improves research quality",
],
"evidence_needs": ["Primary documentation", "Comparable implementation examples"],
"visual_needs": ["Workflow diagram", "Evidence checklist"],
"seo_notes": [f"Use {keyword} in title, intro, and comparison headings"],
"source_requirements": ["Prefer primary vendor or standards sources"],
"excluded_sources": ["Unsourced competitor claims"],
"tone": target_site.brand_voice,
"audience": audience,
"risks": ["Claims may need evidence review", "Scope can drift without exclusions"],
"sections": [
{
"heading": "Context and scope",
"purpose": "Define reader problem and article boundary",
"key_points": ["Current workflow", "Decision criteria"],
"evidence_needs": ["Brief", "Boundary answers"],
"claims_to_support": ["Governed workflows reduce editorial risk"],
"target_word_count": 400,
},
{
"heading": "Implementation workflow",
"purpose": "Explain how the workflow should operate",
"key_points": ["Roles", "Automation", "Review gates"],
"evidence_needs": ["Architecture notes"],
"claims_to_support": ["Plan approval improves research quality"],
"target_word_count": 600,
},
{
"heading": "Evidence and quality controls",
"purpose": "Describe source standards and claim checks",
"key_points": ["Source requirements", "Unsupported claim handling"],
"evidence_needs": ["Source policy"],
"claims_to_support": ["Governed workflows reduce editorial risk"],
"target_word_count": 500,
},
{
"heading": "Risks and next steps",
"purpose": "Prepare readers for implementation tradeoffs",
"key_points": ["Operational risks", "Rollout sequence"],
"evidence_needs": ["Comparable examples"],
"claims_to_support": ["Plan approval improves research quality"],
"target_word_count": 400,
},
],
}
def _plan_payload_from_summary(plan: PlanSummary) -> dict[str, object]:
return {
"title_options": plan.title_options,
"recommended_title": plan.recommended_title,
"reader_persona": plan.reader_persona,
"search_intent": plan.search_intent,
"thesis": plan.thesis,
"claims_to_prove": plan.claims_to_prove,
"evidence_needs": plan.evidence_needs,
"visual_needs": plan.visual_needs,
"seo_notes": plan.seo_notes,
"source_requirements": plan.source_requirements,
"excluded_sources": plan.excluded_sources,
"tone": plan.tone,
"audience": plan.audience,
"risks": plan.risks,
"sections": [
{
"heading": section.heading,
"purpose": section.purpose,
"key_points": section.key_points,
"evidence_needs": section.evidence_needs,
"claims_to_support": section.claims_to_support,
"target_word_count": section.target_word_count,
}
for section in plan.sections
],
}
def _review_response(
*,
article_id: UUID,
reviewer_id: UUID,
status: ReviewStatus,
notes: str | None,
) -> ReviewActionResponse:
return ReviewActionResponse(
review=ReviewSummary(
id=uuid4(),
article_id=article_id,
review_type=ReviewType.PLAN,
status=status,
reviewer_id=reviewer_id,
notes=notes,
created_at=_now(),
)
)
def _now() -> datetime:
return datetime.now(UTC)
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
import hashlib
import json
from datetime import UTC, datetime
from urllib.parse import urlparse
from uuid import UUID, uuid4
from src.domain.contracts import (
AgentJobStatus,
AgentJobType,
ArticleWorkflowStatus,
PlanReviewStatus,
ResearchListResponse,
ResearchStartResponse,
)
from src.infrastructure.object_storage import ObjectStorageClient
def start_research_run(
repository: object,
*,
article_id: UUID,
object_storage: ObjectStorageClient,
) -> ResearchStartResponse:
article = repository.articles.get(article_id)
approved_plan = _approved_plan(repository, article_id)
if approved_plan is None:
raise PermissionError("Plan must be approved before research can start")
now = _now()
if article.status != ArticleWorkflowStatus.RESEARCH_RUNNING:
article = repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.RESEARCH_RUNNING,
updated_at=now,
)
job = repository.agent_jobs.create(
article_id=article_id,
parent_job_id=None,
attempt=1,
job_type=AgentJobType.RESEARCH,
agent_profile="fake-research-fetch",
status=AgentJobStatus.SUCCEEDED,
input_files=[{"path": "inputs/approved-plan.json", "content_hash": None}],
queued_at=now,
)
run_id = uuid4()
s3_prefix = f"research/{article_id}/{run_id}"
artifacts = _build_fake_research_artifacts(
article=article,
plan=approved_plan,
s3_prefix=s3_prefix,
object_storage=object_storage,
retrieved_at=now,
)
manifest = repository.research_manifests.create(
article_id=article_id,
agent_job_id=job.id,
s3_prefix=s3_prefix,
artifacts=artifacts,
created_at=now,
)
job = repository.agent_jobs.complete(
job_id=job.id,
status=AgentJobStatus.SUCCEEDED,
workspace_path=None,
output_files=[{"path": artifact["object_key"]} for artifact in artifacts],
error_category=None,
error_message=None,
stdout="fake research fetch completed\n",
stderr="",
exit_code=0,
duration_ms=0,
finished_at=now,
)
return ResearchStartResponse(article=article, job=job, manifest=manifest)
def list_research_runs(repository: object, *, article_id: UUID) -> ResearchListResponse:
repository.articles.get(article_id)
return ResearchListResponse(
manifests=repository.research_manifests.list_for_article(article_id),
)
def _approved_plan(repository: object, article_id: UUID) -> object | None:
for plan in reversed(repository.article_plans.list_for_article(article_id)):
if plan.status == PlanReviewStatus.APPROVED:
return plan
return None
def _build_fake_research_artifacts(
*,
article: object,
plan: object,
s3_prefix: str,
object_storage: ObjectStorageClient,
retrieved_at: datetime,
) -> list[dict[str, object]]:
sources = [
{
"url": "https://example.com/research/content-governance",
"title": "Content governance implementation guide",
"source_type": "primary_reference",
"summary": "Governance programs need explicit review gates and source evidence.",
"section": "A source-section snapshot about governance workflow controls.",
"ip": "93.184.216.34",
"whois": "Example Domain Registry",
},
{
"url": "https://example.org/research/evidence-standards",
"title": "Evidence standards for editorial claims",
"source_type": "standards_reference",
"summary": "Editorial claims should be linked to durable source artifacts.",
"section": "A source-section snapshot about evidence standards and claim review.",
"ip": "93.184.216.34",
"whois": "Example Organization Registry",
},
]
artifacts: list[dict[str, object]] = []
for index, source in enumerate(sources, start=1):
parsed = urlparse(str(source["url"]))
base_key = f"{s3_prefix}/source-{index}"
content_key = f"{base_key}/section.json"
metadata_key = f"{base_key}/metadata.json"
payload = {
"article_id": str(article.id),
"plan_id": str(plan.id),
"plan_version": plan.version,
"source_url": source["url"],
"section_snapshot": source["section"],
"retrieved_at": retrieved_at.isoformat(),
}
metadata = {
"source_title": source["title"],
"domain": parsed.netloc,
"source_type": source["source_type"],
"summary": source["summary"],
"retrieved_at": retrieved_at.isoformat(),
"artifact_link": content_key,
"metadata_object_key": metadata_key,
"search_path": [
article.primary_keyword or article.working_title or "article research",
str(source["title"]),
],
"selection_criteria": [
"Matches approved plan evidence needs",
"Deterministic fake fixture for demo and tests",
],
"head_metadata": {
"title": source["title"],
"description": source["summary"],
"canonical": source["url"],
},
"server_ip_address": source["ip"],
"whois_owner": source["whois"],
}
content = json.dumps(payload, sort_keys=True, indent=2)
metadata_content = json.dumps(metadata, sort_keys=True, indent=2)
object_storage.put_text(object_key=content_key, content=content)
object_storage.put_text(object_key=metadata_key, content=metadata_content)
artifacts.append(
{
"artifact_type": "source_section_snapshot",
"source_url": source["url"],
"object_key": content_key,
"content_hash": hashlib.sha256(content.encode("utf-8")).hexdigest(),
"metadata": metadata,
}
)
return artifacts
def _now() -> datetime:
return datetime.now(UTC)
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
from datetime import UTC, datetime
from hashlib import sha256
from uuid import NAMESPACE_URL, UUID, uuid5
from src.domain.contracts import (
PublishingRules,
Role,
ScriptConfigVersionStatus,
)
from src.domain.schema import (
SEEDED_ADMIN_EMAIL,
SEEDED_EDITOR_EMAIL,
SEEDED_TARGET_SITE_SLUG,
)
SEED_TIMESTAMP = datetime(2026, 1, 1, tzinfo=UTC)
def seed_reference_data(repository: object) -> None:
admin_id = _stable_uuid("user:admin")
editor_id = _stable_uuid("user:editor")
target_site_id = _stable_uuid(f"target-site:{SEEDED_TARGET_SITE_SLUG}")
script_config_version_id = _stable_uuid(
f"script-config-version:{SEEDED_TARGET_SITE_SLUG}:1"
)
repository.users.upsert(
user_id=admin_id,
email=SEEDED_ADMIN_EMAIL,
display_name="Admin User",
role=Role.ADMIN,
created_at=SEED_TIMESTAMP,
updated_at=SEED_TIMESTAMP,
)
repository.users.upsert(
user_id=editor_id,
email=SEEDED_EDITOR_EMAIL,
display_name="Editor User",
role=Role.EDITOR,
created_at=SEED_TIMESTAMP,
updated_at=SEED_TIMESTAMP,
)
publishing_rules = PublishingRules(
repository_url="git@github.com:example/site.git",
production_branch="main",
content_format="mdx",
content_path_template="content/articles/{slug}.mdx",
asset_path_template="public/articles/{slug}/{filename}",
frontmatter_mapping={
"title": "title",
"description": "meta_description",
"publishedAt": "published_at",
},
transform_script_version_id=script_config_version_id,
dry_run_renderer="next-mdx",
)
repository.target_sites.upsert(
site_id=target_site_id,
name="B2B SaaS Blog",
slug=SEEDED_TARGET_SITE_SLUG,
publishing_type="git_next",
default_language="en",
brand_voice="Clear, practical, evidence-led B2B editorial voice.",
audience="B2B SaaS founders and growth teams.",
seo_rules={
"title_max_length": 60,
"meta_description_max_length": 155,
"primary_keyword_required": True,
},
visual_rules={
"hero_style": "product editorial",
"diagram_format": "mermaid",
},
source_rules={
"minimum_sources": 3,
"preferred_source_types": ["primary", "analyst", "vendor_docs"],
},
publishing_rules=publishing_rules,
active_script_config_version_id=script_config_version_id,
created_at=SEED_TIMESTAMP,
updated_at=SEED_TIMESTAMP,
)
publishing_yaml = """target: b2b_saas_blog
repository:
url: git@github.com:example/site.git
branch: main
content:
format: mdx
path_template: content/articles/{slug}.mdx
assets:
path_template: public/articles/{slug}/{filename}
"""
transform_script = """export function transformArticle(article) {
return {
frontmatter: article.frontmatter,
body: article.body,
assets: article.assets,
};
}
"""
repository.script_config_versions.upsert(
version_id=script_config_version_id,
target_site_id=target_site_id,
version=1,
status=ScriptConfigVersionStatus.ACTIVE,
created_by=admin_id,
created_at=SEED_TIMESTAMP,
updated_at=SEED_TIMESTAMP,
diff={"summary": "Initial Git-backed Next.js publishing configuration."},
rollback_target_version_id=None,
activated_at=SEED_TIMESTAMP,
publishing_yaml=publishing_yaml,
publishing_yaml_hash=_sha256(publishing_yaml),
transform_script=transform_script,
transform_script_hash=_sha256(transform_script),
)
def _stable_uuid(value: str) -> UUID:
return uuid5(NAMESPACE_URL, f"ai-content-pipeline:{value}")
def _sha256(value: str) -> str:
return sha256(value.encode("utf-8")).hexdigest()
+266
View File
@@ -0,0 +1,266 @@
from __future__ import annotations
from datetime import UTC, datetime
from hashlib import sha256
from uuid import NAMESPACE_URL, UUID, uuid5
from src.domain.contracts import (
CurrentUser,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionResponse,
ScriptConfigVersionStatus,
ScriptConfigVersionSummary,
TargetSiteConfigCreateRequest,
TargetSiteConfigResponse,
TargetSiteConfigUpdateRequest,
)
_VERSION_CREATED_EVENT_TYPE = "SCRIPT_CONFIG_VERSION_CREATED"
_VERSION_ACTIVATED_EVENT_TYPE = "SCRIPT_CONFIG_VERSION_ACTIVATED"
_VERSION_ROLLBACK_EVENT_TYPE = "SCRIPT_CONFIG_VERSION_ROLLBACK"
def create_target_site(
repository: object,
request: TargetSiteConfigCreateRequest,
) -> TargetSiteConfigResponse:
now = _now()
site = repository.target_sites.upsert(
site_id=_stable_uuid(f"target-site:{request.slug}"),
name=request.name,
slug=request.slug,
publishing_type=request.publishing_type,
default_language=request.default_language,
brand_voice=request.brand_voice,
audience=request.audience,
seo_rules=request.seo_rules,
visual_rules=request.visual_rules,
source_rules=request.source_rules,
publishing_rules=request.publishing_rules,
active_script_config_version_id=request.active_script_config_version_id,
created_at=now,
updated_at=now,
)
return TargetSiteConfigResponse(site=site)
def get_target_site(
repository: object,
site_id: UUID,
) -> TargetSiteConfigResponse:
site = repository.target_sites.get_by_id(site_id)
return TargetSiteConfigResponse(site=site)
def update_target_site(
repository: object,
site_id: UUID,
request: TargetSiteConfigUpdateRequest,
) -> TargetSiteConfigResponse:
existing = repository.target_sites.get_by_id(site_id)
changes = request.model_dump(exclude_unset=True)
publishing_rules = (
request.publishing_rules
if "publishing_rules" in changes
else existing.publishing_rules
)
now = _now()
site = repository.target_sites.update(
site_id=existing.id,
name=changes.get("name", existing.name),
slug=changes.get("slug", existing.slug),
publishing_type=changes.get("publishing_type", existing.publishing_type),
default_language=changes.get("default_language", existing.default_language),
brand_voice=changes.get("brand_voice", existing.brand_voice),
audience=changes.get("audience", existing.audience),
seo_rules=changes.get("seo_rules", existing.seo_rules),
visual_rules=changes.get("visual_rules", existing.visual_rules),
source_rules=changes.get("source_rules", existing.source_rules),
publishing_rules=publishing_rules,
active_script_config_version_id=changes.get(
"active_script_config_version_id",
existing.active_script_config_version_id,
),
updated_at=now,
)
return TargetSiteConfigResponse(site=site)
def create_script_config_version(
repository: object,
*,
site_id: UUID,
current_user: CurrentUser,
request: ScriptConfigVersionCreateRequest,
) -> ScriptConfigVersionResponse:
repository.target_sites.get_by_id(site_id)
if request.rollback_target_version_id is not None:
rollback_target = repository.script_config_versions.get_by_id(
request.rollback_target_version_id
)
if str(rollback_target["target_site_id"]) != str(site_id):
raise LookupError(
f"Rollback target version is not for site: {request.rollback_target_version_id}"
)
existing_versions = repository.script_config_versions.list_for_site(site_id)
version = request.version or _next_version(existing_versions)
now = _now()
version_id = _stable_uuid(f"script-config-version:{site_id}:{version}")
status = (
ScriptConfigVersionStatus.ACTIVE
if request.activate
else ScriptConfigVersionStatus.DRAFT
)
row = repository.script_config_versions.upsert(
version_id=version_id,
target_site_id=site_id,
version=version,
status=status,
created_by=current_user.id,
created_at=now,
updated_at=now,
diff=request.diff,
rollback_target_version_id=request.rollback_target_version_id,
activated_at=now if request.activate else None,
publishing_yaml=request.publishing_yaml,
publishing_yaml_hash=_sha256(request.publishing_yaml),
transform_script=request.transform_script,
transform_script_hash=_sha256(request.transform_script),
)
if request.activate:
row = repository.script_config_versions.activate(
target_site_id=site_id,
version_id=version_id,
activated_at=now,
rollback_target_version_id=request.rollback_target_version_id,
)
repository.script_config_version_events.create(
target_site_id=site_id,
version_id=version_id,
event_type=_VERSION_ACTIVATED_EVENT_TYPE,
actor_user_id=current_user.id,
payload={"action": "activate", "version": version},
created_at=now,
)
else:
repository.script_config_version_events.create(
target_site_id=site_id,
version_id=version_id,
event_type=_VERSION_CREATED_EVENT_TYPE,
actor_user_id=current_user.id,
payload={"action": "create", "version": version},
created_at=now,
)
return ScriptConfigVersionResponse(version=_script_version_summary(row))
def activate_script_config_version(
repository: object,
*,
site_id: UUID,
version_id: UUID,
current_user: CurrentUser,
) -> ScriptConfigVersionResponse:
repository.target_sites.get_by_id(site_id)
row = repository.script_config_versions.activate(
target_site_id=site_id,
version_id=version_id,
activated_at=_now(),
)
repository.script_config_version_events.create(
target_site_id=site_id,
version_id=version_id,
event_type=_VERSION_ACTIVATED_EVENT_TYPE,
actor_user_id=current_user.id,
payload={"action": "activate", "version_id": str(version_id)},
created_at=_now(),
)
return ScriptConfigVersionResponse(version=_script_version_summary(row))
def rollback_script_config_version(
repository: object,
*,
site_id: UUID,
version_id: UUID,
current_user: CurrentUser,
) -> ScriptConfigVersionResponse:
target_site = repository.target_sites.get_by_id(site_id)
version_row = repository.script_config_versions.get_by_id(version_id)
if str(version_row["target_site_id"]) != str(site_id):
raise LookupError(
f"Script config version not found for site: {site_id} {version_id}"
)
rollback_target_id = target_site.active_script_config_version_id
if rollback_target_id == version_id:
rollback_target_id = None
row = repository.script_config_versions.activate(
target_site_id=site_id,
version_id=version_id,
activated_at=_now(),
rollback_target_version_id=rollback_target_id,
)
repository.script_config_version_events.create(
target_site_id=site_id,
version_id=version_id,
event_type=_VERSION_ROLLBACK_EVENT_TYPE,
actor_user_id=current_user.id,
payload={
"action": "rollback",
"rollback_target_version_id": (
str(rollback_target_id) if rollback_target_id is not None else None
),
},
created_at=_now(),
)
return ScriptConfigVersionResponse(version=_script_version_summary(row))
def list_script_config_versions(
repository: object,
site_id: UUID,
) -> list[ScriptConfigVersionSummary]:
repository.target_sites.get_by_id(site_id)
rows = repository.script_config_versions.list_for_site(site_id)
return [_script_version_summary(row) for row in rows]
def _script_version_summary(row: dict[str, object]) -> ScriptConfigVersionSummary:
return ScriptConfigVersionSummary(
id=row["id"],
target_site_id=row["target_site_id"],
version=row["version"],
status=row["status"],
diff=row["diff"],
rollback_target_version_id=row["rollback_target_version_id"],
publishing_yaml_hash=row["publishing_yaml_hash"],
transform_script_hash=row["transform_script_hash"],
created_by=row["created_by"],
created_at=row["created_at"],
activated_at=row["activated_at"],
)
def _next_version(rows: list[dict[str, object]]) -> int:
versions = [int(row["version"]) for row in rows]
if not versions:
return 1
return max(versions) + 1
def _stable_uuid(value: str) -> UUID:
return uuid5(NAMESPACE_URL, f"ai-content-pipeline:{value}")
def _sha256(value: str) -> str:
return sha256(value.encode("utf-8")).hexdigest()
def _now() -> datetime:
return datetime.now(UTC)
+7
View File
@@ -0,0 +1,7 @@
from __future__ import annotations
from src.domain.contracts.enums import Role
ADMIN_ROLES = frozenset({Role.ADMIN})
EDITOR_OR_ADMIN_ROLES = frozenset({Role.ADMIN, Role.EDITOR})
@@ -0,0 +1,135 @@
from .enums import (
AgentJobErrorCategory,
AgentJobStatus,
AgentJobType,
ArticleWorkflowStatus,
AssetStatus,
AssetType,
ClaimRiskLevel,
ClaimSupportStatus,
PlanReviewStatus,
PublishingStatus,
ReviewStatus,
ReviewType,
Role,
ScriptConfigVersionStatus,
)
from .models import (
AgentJobOutput,
AgentJobListResponse,
AgentJobResponse,
AgentJobSummary,
AgentJobTestCodexRequest,
ArticleCreateRequest,
ArticleCreateResponse,
ArticleDetailResponse,
ArticleListResponse,
ArticleSummary,
BoundaryQuestionListResponse,
BoundaryQuestionResponse,
BoundaryQuestionSummary,
BoundaryQuestionUpdateRequest,
WorkflowEventSummary,
AssetSummary,
ClaimSummary,
CurrentUser,
CurrentUserResponse,
DraftSummary,
EvidenceSummary,
PlanSectionSummary,
PlanSectionEditRequest,
PlanListResponse,
PlanResponse,
PlanRevisionRequest,
PlanSummary,
PlanUpdateRequest,
PublishCommitSummary,
PublishingRules,
ResearchArtifactManifestSummary,
ResearchArtifactSummary,
ResearchListResponse,
ResearchStartResponse,
ReviewActionResponse,
ReviewSummary,
RunnerFileRef,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse,
ScriptConfigVersionResponse,
ScriptConfigVersionSummary,
TargetSiteConfig,
TargetSiteConfigCreateRequest,
TargetSiteConfigResponse,
TargetSiteConfigUpdateRequest,
UserSummary,
)
from .workflow import (
ARTICLE_WORKFLOW_STATUSES,
ARTICLE_WORKFLOW_TRANSITIONS,
next_workflow_statuses,
)
__all__ = [
"ARTICLE_WORKFLOW_STATUSES",
"ARTICLE_WORKFLOW_TRANSITIONS",
"AgentJobErrorCategory",
"AgentJobListResponse",
"AgentJobOutput",
"AgentJobResponse",
"AgentJobStatus",
"AgentJobSummary",
"AgentJobTestCodexRequest",
"AgentJobType",
"ArticleCreateRequest",
"ArticleCreateResponse",
"ArticleDetailResponse",
"ArticleListResponse",
"ArticleSummary",
"BoundaryQuestionListResponse",
"BoundaryQuestionResponse",
"BoundaryQuestionSummary",
"BoundaryQuestionUpdateRequest",
"WorkflowEventSummary",
"ArticleWorkflowStatus",
"AssetStatus",
"AssetSummary",
"AssetType",
"ClaimRiskLevel",
"ClaimSummary",
"ClaimSupportStatus",
"CurrentUser",
"CurrentUserResponse",
"DraftSummary",
"EvidenceSummary",
"PlanReviewStatus",
"PlanListResponse",
"PlanResponse",
"PlanRevisionRequest",
"PlanSectionEditRequest",
"PlanSectionSummary",
"PlanSummary",
"PlanUpdateRequest",
"PublishCommitSummary",
"PublishingRules",
"PublishingStatus",
"ResearchArtifactManifestSummary",
"ResearchArtifactSummary",
"ResearchListResponse",
"ResearchStartResponse",
"ReviewActionResponse",
"ReviewStatus",
"ReviewSummary",
"ReviewType",
"Role",
"RunnerFileRef",
"ScriptConfigVersionCreateRequest",
"ScriptConfigVersionListResponse",
"ScriptConfigVersionResponse",
"ScriptConfigVersionStatus",
"ScriptConfigVersionSummary",
"TargetSiteConfig",
"TargetSiteConfigCreateRequest",
"TargetSiteConfigResponse",
"TargetSiteConfigUpdateRequest",
"UserSummary",
"next_workflow_statuses",
]
+126
View File
@@ -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"
+451
View File
@@ -0,0 +1,451 @@
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 CurrentUser(ContractModel):
id: UUID
email: str = Field(min_length=1)
display_name: str = Field(min_length=1)
role: Role
class CurrentUserResponse(ContractModel):
user: CurrentUser
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 TargetSiteConfigCreateRequest(ContractModel):
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
class TargetSiteConfigUpdateRequest(ContractModel):
name: str | None = Field(default=None, min_length=1)
slug: str | None = Field(default=None, min_length=1)
publishing_type: str | None = Field(default=None, min_length=1)
default_language: str | None = Field(default=None, min_length=2)
brand_voice: str | None = Field(default=None, min_length=1)
audience: str | None = Field(default=None, min_length=1)
seo_rules: JsonObject | None = None
visual_rules: JsonObject | None = None
source_rules: JsonObject | None = None
publishing_rules: PublishingRules | None = None
active_script_config_version_id: UUID | None = None
class TargetSiteConfigResponse(ContractModel):
site: TargetSiteConfig
class ScriptConfigVersionSummary(ContractModel):
id: UUID
target_site_id: UUID
version: int = Field(ge=1)
status: ScriptConfigVersionStatus
diff: JsonObject = Field(default_factory=dict)
publishing_yaml_hash: str = Field(min_length=1)
transform_script_hash: str = Field(min_length=1)
rollback_target_version_id: UUID | None = None
created_by: UUID
created_at: datetime
activated_at: datetime | None = None
class ScriptConfigVersionCreateRequest(ContractModel):
publishing_yaml: str = Field(min_length=1)
transform_script: str = Field(min_length=1)
diff: JsonObject = Field(default_factory=dict)
rollback_target_version_id: UUID | None = None
activate: bool = False
version: int | None = Field(default=None, ge=1)
class ScriptConfigVersionResponse(ContractModel):
version: ScriptConfigVersionSummary
class ScriptConfigVersionListResponse(ContractModel):
versions: list[ScriptConfigVersionSummary]
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 WorkflowEventSummary(ContractModel):
id: UUID
article_id: UUID
event_type: str = Field(min_length=1)
from_status: ArticleWorkflowStatus | None = None
to_status: ArticleWorkflowStatus | None = None
actor_user_id: UUID | None = None
payload: JsonObject = Field(default_factory=dict)
created_at: datetime
class BoundaryQuestionSummary(ContractModel):
id: UUID
article_id: UUID
sort_order: int = Field(ge=1)
category: str = Field(min_length=1)
question: str = Field(min_length=1)
answer: str | None = None
is_required: bool = True
created_at: datetime
updated_at: datetime
class BoundaryQuestionUpdateRequest(ContractModel):
answer: str | None = None
class BoundaryQuestionResponse(ContractModel):
question: BoundaryQuestionSummary
class BoundaryQuestionListResponse(ContractModel):
questions: list[BoundaryQuestionSummary]
job: AgentJobSummary | None = None
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
key_points: list[str] = Field(default_factory=list)
evidence_needs: list[str] = Field(default_factory=list)
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
title_options: list[str] = Field(default_factory=list)
recommended_title: str | None = None
reader_persona: 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)
evidence_needs: list[str] = Field(default_factory=list)
visual_needs: list[str] = Field(default_factory=list)
seo_notes: list[str] = Field(default_factory=list)
source_requirements: list[str] = Field(default_factory=list)
excluded_sources: list[str] = Field(default_factory=list)
tone: str | None = None
audience: str | None = None
risks: list[str] = Field(default_factory=list)
created_at: datetime
class PlanSectionEditRequest(ContractModel):
heading: str = Field(min_length=1)
purpose: str | None = None
key_points: list[str] = Field(default_factory=list)
evidence_needs: list[str] = Field(default_factory=list)
claims_to_support: list[str] = Field(default_factory=list)
target_word_count: int | None = Field(default=None, ge=1)
class PlanUpdateRequest(ContractModel):
title_options: list[str] | None = None
recommended_title: str | None = None
reader_persona: str | None = None
search_intent: str | None = None
thesis: str | None = None
sections: list[PlanSectionEditRequest] | None = None
claims_to_prove: list[str] | None = None
evidence_needs: list[str] | None = None
visual_needs: list[str] | None = None
seo_notes: list[str] | None = None
source_requirements: list[str] | None = None
excluded_sources: list[str] | None = None
tone: str | None = None
audience: str | None = None
risks: list[str] | None = None
class PlanRevisionRequest(ContractModel):
notes: str = Field(min_length=1)
class PlanResponse(ContractModel):
plan: PlanSummary
class PlanListResponse(ContractModel):
plans: list[PlanSummary]
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 ResearchStartResponse(ContractModel):
article: ArticleSummary
job: AgentJobSummary
manifest: ResearchArtifactManifestSummary
class ResearchListResponse(ContractModel):
manifests: list[ResearchArtifactManifestSummary] = Field(default_factory=list)
evidence: list["EvidenceSummary"] = Field(default_factory=list)
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 ReviewActionResponse(ContractModel):
review: ReviewSummary
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 | None = None
parent_job_id: UUID | None = None
attempt: int = Field(default=1, ge=1)
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
stdout: str = ""
stderr: str = ""
exit_code: int | None = None
duration_ms: int | None = Field(default=None, ge=0)
queued_at: datetime
started_at: datetime | None = None
finished_at: datetime | None = None
class AgentJobTestCodexRequest(ContractModel):
fake_result: str = Field(default="valid", pattern="^(valid|invalid_schema)$")
class AgentJobResponse(ContractModel):
job: AgentJobSummary
class AgentJobListResponse(ContractModel):
jobs: list[AgentJobSummary]
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
workflow_events: list[WorkflowEventSummary] = Field(default_factory=list)
boundary_questions: list[BoundaryQuestionSummary] = Field(default_factory=list)
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,153 @@
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,
AgentJobListResponse,
AgentJobResponse,
AgentJobSummary,
AgentJobTestCodexRequest,
ArticleCreateRequest,
ArticleCreateResponse,
ArticleDetailResponse,
ArticleListResponse,
ArticleSummary,
BoundaryQuestionListResponse,
BoundaryQuestionResponse,
BoundaryQuestionSummary,
BoundaryQuestionUpdateRequest,
AssetSummary,
ClaimSummary,
CurrentUser,
CurrentUserResponse,
DraftSummary,
EvidenceSummary,
PlanSectionSummary,
PlanSectionEditRequest,
PlanListResponse,
PlanResponse,
PlanRevisionRequest,
PlanSummary,
PlanUpdateRequest,
PublishCommitSummary,
PublishingRules,
ResearchArtifactManifestSummary,
ResearchArtifactSummary,
ResearchListResponse,
ResearchStartResponse,
ReviewActionResponse,
ReviewSummary,
RunnerFileRef,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse,
ScriptConfigVersionResponse,
ScriptConfigVersionSummary,
TargetSiteConfig,
TargetSiteConfigCreateRequest,
TargetSiteConfigResponse,
WorkflowEventSummary,
TargetSiteConfigUpdateRequest,
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,
CurrentUser,
CurrentUserResponse,
PublishingRules,
TargetSiteConfig,
TargetSiteConfigCreateRequest,
TargetSiteConfigUpdateRequest,
TargetSiteConfigResponse,
ScriptConfigVersionSummary,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionResponse,
ScriptConfigVersionListResponse,
ArticleCreateRequest,
ArticleSummary,
ArticleCreateResponse,
ArticleListResponse,
WorkflowEventSummary,
BoundaryQuestionSummary,
BoundaryQuestionUpdateRequest,
BoundaryQuestionResponse,
BoundaryQuestionListResponse,
PlanSectionSummary,
PlanSectionEditRequest,
PlanSummary,
PlanUpdateRequest,
PlanRevisionRequest,
PlanResponse,
PlanListResponse,
ResearchArtifactSummary,
ResearchArtifactManifestSummary,
ResearchStartResponse,
ResearchListResponse,
EvidenceSummary,
ClaimSummary,
DraftSummary,
AssetSummary,
ReviewSummary,
ReviewActionResponse,
PublishCommitSummary,
RunnerFileRef,
AgentJobSummary,
AgentJobTestCodexRequest,
AgentJobResponse,
AgentJobListResponse,
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]
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
USERS_TABLE = "users"
TARGET_SITES_TABLE = "target_sites"
SCRIPT_CONFIG_VERSIONS_TABLE = "script_config_versions"
ARTICLES_TABLE = "articles"
BOUNDARY_QUESTIONS_TABLE = "boundary_questions"
ARTICLE_PLANS_TABLE = "article_plans"
PLAN_SECTIONS_TABLE = "plan_sections"
EVIDENCE_ITEMS_TABLE = "evidence_items"
CLAIMS_TABLE = "claims"
ARTICLE_DRAFTS_TABLE = "article_drafts"
ASSETS_TABLE = "assets"
WORKFLOW_EVENTS_TABLE = "workflow_events"
AGENT_JOBS_TABLE = "agent_jobs"
RESEARCH_RUN_MANIFESTS_TABLE = "research_run_manifests"
PUBLISH_COMMITS_TABLE = "publish_commits"
PROMPT_VERSIONS_TABLE = "prompt_versions"
CORE_TABLES: tuple[str, ...] = (
USERS_TABLE,
TARGET_SITES_TABLE,
SCRIPT_CONFIG_VERSIONS_TABLE,
ARTICLES_TABLE,
BOUNDARY_QUESTIONS_TABLE,
ARTICLE_PLANS_TABLE,
PLAN_SECTIONS_TABLE,
EVIDENCE_ITEMS_TABLE,
CLAIMS_TABLE,
ARTICLE_DRAFTS_TABLE,
ASSETS_TABLE,
WORKFLOW_EVENTS_TABLE,
AGENT_JOBS_TABLE,
RESEARCH_RUN_MANIFESTS_TABLE,
PUBLISH_COMMITS_TABLE,
PROMPT_VERSIONS_TABLE,
)
SEEDED_ADMIN_EMAIL = "admin@example.com"
SEEDED_EDITOR_EMAIL = "editor@example.com"
SEEDED_TARGET_SITE_SLUG = "b2b_saas_blog"
+14
View File
@@ -2,3 +2,17 @@
Database, cache, object storage, queue, and other external adapters belong in Database, cache, object storage, queue, and other external adapters belong in
this layer. this layer.
## Database
Run migrations from the repository root:
```bash
DATABASE_URL=postgresql://pipeline:pipeline_local@localhost:5432/pipeline alembic upgrade head
```
`POSTGRES_DSN` is also supported. Reference data can be seeded with:
```bash
DATABASE_URL=postgresql://pipeline:pipeline_local@localhost:5432/pipeline make seed
```
@@ -0,0 +1,63 @@
from __future__ import annotations
import os
from pathlib import Path
class ObjectStorageClient:
def put_text(self, *, object_key: str, content: str) -> str:
raise NotImplementedError
class LocalObjectStorageClient(ObjectStorageClient):
def __init__(self, root: Path) -> None:
self.root = root
def put_text(self, *, object_key: str, content: str) -> str:
path = self.root / object_key
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return f"file://{path}"
class S3ObjectStorageClient(ObjectStorageClient):
def __init__(self) -> None:
import boto3
from botocore.config import Config
self.bucket = _required_env("OBJECT_STORAGE_BUCKET")
self.client = boto3.client(
"s3",
endpoint_url=_required_env("OBJECT_STORAGE_ENDPOINT"),
aws_access_key_id=_required_env("OBJECT_STORAGE_ACCESS_KEY_ID"),
aws_secret_access_key=_required_env("OBJECT_STORAGE_SECRET_ACCESS_KEY"),
region_name=os.environ.get("OBJECT_STORAGE_REGION", "us-east-1"),
config=Config(signature_version="s3v4"),
)
def put_text(self, *, object_key: str, content: str) -> str:
self.client.put_object(
Bucket=self.bucket,
Key=object_key,
Body=content.encode("utf-8"),
ContentType="application/json; charset=utf-8",
)
return f"s3://{self.bucket}/{object_key}"
def open_object_storage_client() -> ObjectStorageClient:
local_root = os.environ.get("OBJECT_STORAGE_LOCAL_ROOT")
if local_root:
return LocalObjectStorageClient(Path(local_root))
if os.environ.get("OBJECT_STORAGE_ENDPOINT"):
return S3ObjectStorageClient()
return LocalObjectStorageClient(Path("/tmp/pipeline-object-storage"))
def _required_env(name: str) -> str:
value = os.environ.get(name)
if not value:
raise RuntimeError(f"Missing required environment variable: {name}")
return value
File diff suppressed because it is too large Load Diff
+690
View File
@@ -0,0 +1,690 @@
from __future__ import annotations
import sqlite3
from collections.abc import Iterable
from contextlib import closing
from src.domain.contracts import (
AgentJobErrorCategory,
AgentJobStatus,
AgentJobType,
ArticleWorkflowStatus,
AssetStatus,
AssetType,
ClaimRiskLevel,
ClaimSupportStatus,
PlanReviewStatus,
PublishingStatus,
Role,
ScriptConfigVersionStatus,
)
def _quoted_values(values: Iterable[str]) -> str:
return ", ".join(f"'{value}'" for value in values)
ROLE_VALUES = _quoted_values(role.value for role in Role)
ARTICLE_STATUS_VALUES = _quoted_values(status.value for status in ArticleWorkflowStatus)
PUBLISHING_STATUS_VALUES = _quoted_values(status.value for status in PublishingStatus)
SCRIPT_CONFIG_STATUS_VALUES = _quoted_values(
status.value for status in ScriptConfigVersionStatus
)
PLAN_REVIEW_STATUS_VALUES = _quoted_values(status.value for status in PlanReviewStatus)
CLAIM_SUPPORT_STATUS_VALUES = _quoted_values(
status.value for status in ClaimSupportStatus
)
CLAIM_RISK_LEVEL_VALUES = _quoted_values(level.value for level in ClaimRiskLevel)
ASSET_TYPE_VALUES = _quoted_values(asset_type.value for asset_type in AssetType)
ASSET_STATUS_VALUES = _quoted_values(status.value for status in AssetStatus)
AGENT_JOB_TYPE_VALUES = _quoted_values(job_type.value for job_type in AgentJobType)
AGENT_JOB_STATUS_VALUES = _quoted_values(status.value for status in AgentJobStatus)
AGENT_JOB_ERROR_CATEGORY_VALUES = _quoted_values(
category.value for category in AgentJobErrorCategory
)
POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
'CREATE EXTENSION IF NOT EXISTS "pgcrypto"',
f"""
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ({ROLE_VALUES})),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
"""
CREATE TABLE IF NOT EXISTS target_sites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
publishing_type TEXT NOT NULL DEFAULT 'git_next',
default_language TEXT NOT NULL DEFAULT 'en',
brand_voice TEXT NOT NULL,
audience TEXT NOT NULL,
seo_rules JSONB NOT NULL DEFAULT '{}'::jsonb,
visual_rules JSONB NOT NULL DEFAULT '{}'::jsonb,
source_rules JSONB NOT NULL DEFAULT '{}'::jsonb,
publishing_rules JSONB NOT NULL DEFAULT '{}'::jsonb,
active_script_config_version_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
f"""
CREATE TABLE IF NOT EXISTS script_config_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
target_site_id UUID NOT NULL REFERENCES target_sites(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
status TEXT NOT NULL CHECK (status IN ({SCRIPT_CONFIG_STATUS_VALUES})),
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
diff JSONB NOT NULL DEFAULT '{{}}'::jsonb,
rollback_target_version_id UUID REFERENCES script_config_versions(id),
activated_at TIMESTAMPTZ,
publishing_yaml TEXT NOT NULL,
publishing_yaml_hash TEXT NOT NULL,
transform_script TEXT NOT NULL,
transform_script_hash TEXT NOT NULL,
UNIQUE (target_site_id, version)
)
""",
f"""
CREATE TABLE IF NOT EXISTS articles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
target_site_id UUID NOT NULL REFERENCES target_sites(id),
status TEXT NOT NULL CHECK (status IN ({ARTICLE_STATUS_VALUES})),
publishing_status TEXT NOT NULL CHECK (
publishing_status IN ({PUBLISHING_STATUS_VALUES})
),
brief_description TEXT NOT NULL,
working_title TEXT,
language TEXT NOT NULL DEFAULT 'en',
content_type TEXT NOT NULL DEFAULT 'longform_guide',
primary_keyword TEXT,
assigned_editor_id UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
"""
CREATE TABLE IF NOT EXISTS boundary_questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL,
category TEXT NOT NULL DEFAULT 'general',
question TEXT NOT NULL,
answer TEXT,
is_required BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (article_id, sort_order)
)
""",
"ALTER TABLE boundary_questions ADD COLUMN IF NOT EXISTS category TEXT NOT NULL DEFAULT 'general'",
f"""
CREATE TABLE IF NOT EXISTS article_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
status TEXT NOT NULL CHECK (status IN ({PLAN_REVIEW_STATUS_VALUES})),
title_options JSONB NOT NULL DEFAULT '[]'::jsonb,
recommended_title TEXT,
reader_persona TEXT,
search_intent TEXT,
thesis TEXT,
claims_to_prove JSONB NOT NULL DEFAULT '[]'::jsonb,
evidence_needs JSONB NOT NULL DEFAULT '[]'::jsonb,
visual_needs JSONB NOT NULL DEFAULT '[]'::jsonb,
seo_notes JSONB NOT NULL DEFAULT '[]'::jsonb,
source_requirements JSONB NOT NULL DEFAULT '[]'::jsonb,
excluded_sources JSONB NOT NULL DEFAULT '[]'::jsonb,
tone TEXT,
audience TEXT,
risks JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (article_id, version)
)
""",
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS title_options JSONB NOT NULL DEFAULT '[]'::jsonb",
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS reader_persona TEXT",
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS evidence_needs JSONB NOT NULL DEFAULT '[]'::jsonb",
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS visual_needs JSONB NOT NULL DEFAULT '[]'::jsonb",
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS seo_notes JSONB NOT NULL DEFAULT '[]'::jsonb",
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS source_requirements JSONB NOT NULL DEFAULT '[]'::jsonb",
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS excluded_sources JSONB NOT NULL DEFAULT '[]'::jsonb",
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS tone TEXT",
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS audience TEXT",
"""
CREATE TABLE IF NOT EXISTS plan_sections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_plan_id UUID NOT NULL REFERENCES article_plans(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL,
heading TEXT NOT NULL,
purpose TEXT,
key_points JSONB NOT NULL DEFAULT '[]'::jsonb,
evidence_needs JSONB NOT NULL DEFAULT '[]'::jsonb,
claims_to_support JSONB NOT NULL DEFAULT '[]'::jsonb,
target_word_count INTEGER,
UNIQUE (article_plan_id, sort_order)
)
""",
"ALTER TABLE plan_sections ADD COLUMN IF NOT EXISTS key_points JSONB NOT NULL DEFAULT '[]'::jsonb",
"ALTER TABLE plan_sections ADD COLUMN IF NOT EXISTS evidence_needs JSONB NOT NULL DEFAULT '[]'::jsonb",
"""
CREATE TABLE IF NOT EXISTS evidence_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
source_title TEXT NOT NULL,
source_url TEXT NOT NULL,
source_type TEXT NOT NULL,
source_quality_score NUMERIC(4, 3) NOT NULL,
summary TEXT NOT NULL,
supports_claims JSONB NOT NULL DEFAULT '[]'::jsonb,
artifact_manifest_id UUID,
retrieved_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
f"""
CREATE TABLE IF NOT EXISTS claims (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
section_id UUID REFERENCES plan_sections(id) ON DELETE SET NULL,
claim_text TEXT NOT NULL,
support_status TEXT NOT NULL CHECK (
support_status IN ({CLAIM_SUPPORT_STATUS_VALUES})
),
risk_level TEXT NOT NULL CHECK (risk_level IN ({CLAIM_RISK_LEVEL_VALUES})),
evidence_item_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
f"""
CREATE TABLE IF NOT EXISTS article_drafts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
title TEXT NOT NULL,
slug TEXT NOT NULL,
meta_title TEXT,
meta_description TEXT,
body_object_key TEXT,
status TEXT NOT NULL CHECK (status IN ({ARTICLE_STATUS_VALUES})),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (article_id, version)
)
""",
f"""
CREATE TABLE IF NOT EXISTS assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
asset_type TEXT NOT NULL CHECK (asset_type IN ({ASSET_TYPE_VALUES})),
title TEXT NOT NULL,
prompt TEXT,
file_url TEXT,
alt_text TEXT,
caption TEXT,
status TEXT NOT NULL CHECK (status IN ({ASSET_STATUS_VALUES})),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
f"""
CREATE TABLE IF NOT EXISTS workflow_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
from_status TEXT CHECK (from_status IN ({ARTICLE_STATUS_VALUES})),
to_status TEXT CHECK (to_status IN ({ARTICLE_STATUS_VALUES})),
actor_user_id UUID REFERENCES users(id),
payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
f"""
CREATE TABLE IF NOT EXISTS agent_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID REFERENCES articles(id) ON DELETE SET NULL,
parent_job_id UUID REFERENCES agent_jobs(id) ON DELETE SET NULL,
attempt INTEGER NOT NULL DEFAULT 1,
job_type TEXT NOT NULL CHECK (job_type IN ({AGENT_JOB_TYPE_VALUES})),
agent_profile TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ({AGENT_JOB_STATUS_VALUES})),
workspace_path TEXT,
input_files JSONB NOT NULL DEFAULT '[]'::jsonb,
output_files JSONB NOT NULL DEFAULT '[]'::jsonb,
error_category TEXT CHECK (
error_category IS NULL
OR error_category IN ({AGENT_JOB_ERROR_CATEGORY_VALUES})
),
error_message TEXT,
stdout TEXT NOT NULL DEFAULT '',
stderr TEXT NOT NULL DEFAULT '',
exit_code INTEGER,
duration_ms INTEGER,
queued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ
)
""",
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS parent_job_id UUID REFERENCES agent_jobs(id) ON DELETE SET NULL",
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS attempt INTEGER NOT NULL DEFAULT 1",
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS stdout TEXT NOT NULL DEFAULT ''",
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS stderr TEXT NOT NULL DEFAULT ''",
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS exit_code INTEGER",
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS duration_ms INTEGER",
"""
CREATE TABLE IF NOT EXISTS research_run_manifests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
agent_job_id UUID NOT NULL REFERENCES agent_jobs(id) ON DELETE CASCADE,
s3_prefix TEXT NOT NULL,
source_urls JSONB NOT NULL DEFAULT '[]'::jsonb,
object_keys JSONB NOT NULL DEFAULT '[]'::jsonb,
content_hashes JSONB NOT NULL DEFAULT '[]'::jsonb,
artifact_types JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata_references JSONB NOT NULL DEFAULT '[]'::jsonb,
artifacts JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
f"""
CREATE TABLE IF NOT EXISTS publish_commits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
target_site_id UUID NOT NULL REFERENCES target_sites(id),
repository_url TEXT NOT NULL,
branch TEXT NOT NULL,
commit_sha TEXT,
content_bundle_manifest JSONB NOT NULL DEFAULT '{{}}'::jsonb,
status TEXT NOT NULL CHECK (status IN ({PUBLISHING_STATUS_VALUES})),
deployment_status TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
f"""
CREATE TABLE IF NOT EXISTS prompt_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
prompt_key TEXT NOT NULL,
version INTEGER NOT NULL,
status TEXT NOT NULL CHECK (status IN ({SCRIPT_CONFIG_STATUS_VALUES})),
body TEXT NOT NULL,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
activated_at TIMESTAMPTZ,
diff JSONB NOT NULL DEFAULT '{{}}'::jsonb,
UNIQUE (prompt_key, version)
)
""",
f"""
CREATE TABLE IF NOT EXISTS script_config_version_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
target_site_id UUID NOT NULL REFERENCES target_sites(id) ON DELETE CASCADE,
version_id UUID NOT NULL REFERENCES script_config_versions(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
actor_user_id UUID REFERENCES users(id),
payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
"CREATE INDEX IF NOT EXISTS idx_target_sites_slug ON target_sites (slug)",
"""
CREATE INDEX IF NOT EXISTS idx_articles_dashboard
ON articles (target_site_id, status, publishing_status, updated_at DESC)
""",
"""
CREATE INDEX IF NOT EXISTS idx_articles_editor_dashboard
ON articles (assigned_editor_id, updated_at DESC)
""",
"""
CREATE INDEX IF NOT EXISTS idx_agent_jobs_queue
ON agent_jobs (status, queued_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_agent_jobs_article_timeline
ON agent_jobs (article_id, job_type, queued_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_workflow_events_timeline
ON workflow_events (article_id, created_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_research_run_manifests_article
ON research_run_manifests (article_id, created_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_publish_commits_article
ON publish_commits (article_id, created_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_script_config_version_events_site
ON script_config_version_events (target_site_id, created_at)
""",
)
SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
"""
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
role TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS target_sites (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
publishing_type TEXT NOT NULL DEFAULT 'git_next',
default_language TEXT NOT NULL DEFAULT 'en',
brand_voice TEXT NOT NULL,
audience TEXT NOT NULL,
seo_rules TEXT NOT NULL DEFAULT '{}',
visual_rules TEXT NOT NULL DEFAULT '{}',
source_rules TEXT NOT NULL DEFAULT '{}',
publishing_rules TEXT NOT NULL DEFAULT '{}',
active_script_config_version_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS script_config_versions (
id TEXT PRIMARY KEY,
target_site_id TEXT NOT NULL,
version INTEGER NOT NULL,
status TEXT NOT NULL,
created_by TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
diff TEXT NOT NULL DEFAULT '{}',
rollback_target_version_id TEXT,
activated_at TEXT,
publishing_yaml TEXT NOT NULL,
publishing_yaml_hash TEXT NOT NULL,
transform_script TEXT NOT NULL,
transform_script_hash TEXT NOT NULL,
UNIQUE (target_site_id, version)
)
""",
"""
CREATE TABLE IF NOT EXISTS articles (
id TEXT PRIMARY KEY,
target_site_id TEXT NOT NULL,
status TEXT NOT NULL,
publishing_status TEXT NOT NULL,
brief_description TEXT NOT NULL,
working_title TEXT,
language TEXT NOT NULL DEFAULT 'en',
content_type TEXT NOT NULL DEFAULT 'longform_guide',
primary_keyword TEXT,
assigned_editor_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS boundary_questions (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
sort_order INTEGER NOT NULL,
category TEXT NOT NULL DEFAULT 'general',
question TEXT NOT NULL,
answer TEXT,
is_required INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (article_id, sort_order)
)
""",
"""
CREATE TABLE IF NOT EXISTS article_plans (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
version INTEGER NOT NULL,
status TEXT NOT NULL,
title_options TEXT NOT NULL DEFAULT '[]',
recommended_title TEXT,
reader_persona TEXT,
search_intent TEXT,
thesis TEXT,
claims_to_prove TEXT NOT NULL DEFAULT '[]',
evidence_needs TEXT NOT NULL DEFAULT '[]',
visual_needs TEXT NOT NULL DEFAULT '[]',
seo_notes TEXT NOT NULL DEFAULT '[]',
source_requirements TEXT NOT NULL DEFAULT '[]',
excluded_sources TEXT NOT NULL DEFAULT '[]',
tone TEXT,
audience TEXT,
risks TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (article_id, version)
)
""",
"""
CREATE TABLE IF NOT EXISTS plan_sections (
id TEXT PRIMARY KEY,
article_plan_id TEXT NOT NULL,
sort_order INTEGER NOT NULL,
heading TEXT NOT NULL,
purpose TEXT,
key_points TEXT NOT NULL DEFAULT '[]',
evidence_needs TEXT NOT NULL DEFAULT '[]',
claims_to_support TEXT NOT NULL DEFAULT '[]',
target_word_count INTEGER,
UNIQUE (article_plan_id, sort_order)
)
""",
"""
CREATE TABLE IF NOT EXISTS evidence_items (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
source_title TEXT NOT NULL,
source_url TEXT NOT NULL,
source_type TEXT NOT NULL,
source_quality_score REAL NOT NULL,
summary TEXT NOT NULL,
supports_claims TEXT NOT NULL DEFAULT '[]',
artifact_manifest_id TEXT,
retrieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS claims (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
section_id TEXT,
claim_text TEXT NOT NULL,
support_status TEXT NOT NULL,
risk_level TEXT NOT NULL,
evidence_item_ids TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS article_drafts (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
version INTEGER NOT NULL,
title TEXT NOT NULL,
slug TEXT NOT NULL,
meta_title TEXT,
meta_description TEXT,
body_object_key TEXT,
status TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (article_id, version)
)
""",
"""
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
asset_type TEXT NOT NULL,
title TEXT NOT NULL,
prompt TEXT,
file_url TEXT,
alt_text TEXT,
caption TEXT,
status TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS workflow_events (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
event_type TEXT NOT NULL,
from_status TEXT,
to_status TEXT,
actor_user_id TEXT,
payload TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS agent_jobs (
id TEXT PRIMARY KEY,
article_id TEXT,
parent_job_id TEXT,
attempt INTEGER NOT NULL DEFAULT 1,
job_type TEXT NOT NULL,
agent_profile TEXT NOT NULL,
status TEXT NOT NULL,
workspace_path TEXT,
input_files TEXT NOT NULL DEFAULT '[]',
output_files TEXT NOT NULL DEFAULT '[]',
error_category TEXT,
error_message TEXT,
stdout TEXT NOT NULL DEFAULT '',
stderr TEXT NOT NULL DEFAULT '',
exit_code INTEGER,
duration_ms INTEGER,
queued_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at TEXT,
finished_at TEXT
)
""",
"""
CREATE TABLE IF NOT EXISTS research_run_manifests (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
agent_job_id TEXT NOT NULL,
s3_prefix TEXT NOT NULL,
source_urls TEXT NOT NULL DEFAULT '[]',
object_keys TEXT NOT NULL DEFAULT '[]',
content_hashes TEXT NOT NULL DEFAULT '[]',
artifact_types TEXT NOT NULL DEFAULT '[]',
metadata_references TEXT NOT NULL DEFAULT '[]',
artifacts TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS publish_commits (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
target_site_id TEXT NOT NULL,
repository_url TEXT NOT NULL,
branch TEXT NOT NULL,
commit_sha TEXT,
content_bundle_manifest TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL,
deployment_status TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS prompt_versions (
id TEXT PRIMARY KEY,
prompt_key TEXT NOT NULL,
version INTEGER NOT NULL,
status TEXT NOT NULL,
body TEXT NOT NULL,
created_by TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
activated_at TEXT,
diff TEXT NOT NULL DEFAULT '{}',
UNIQUE (prompt_key, version)
)
""",
"""
CREATE TABLE IF NOT EXISTS script_config_version_events (
id TEXT PRIMARY KEY,
target_site_id TEXT NOT NULL,
version_id TEXT NOT NULL,
event_type TEXT NOT NULL,
actor_user_id TEXT,
payload TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"CREATE INDEX IF NOT EXISTS idx_target_sites_slug ON target_sites (slug)",
"""
CREATE INDEX IF NOT EXISTS idx_articles_dashboard
ON articles (target_site_id, status, publishing_status, updated_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_articles_editor_dashboard
ON articles (assigned_editor_id, updated_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_agent_jobs_queue
ON agent_jobs (status, queued_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_agent_jobs_article_timeline
ON agent_jobs (article_id, job_type, queued_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_workflow_events_timeline
ON workflow_events (article_id, created_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_research_run_manifests_article
ON research_run_manifests (article_id, created_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_publish_commits_article
ON publish_commits (article_id, created_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_script_config_version_events_site
ON script_config_version_events (target_site_id, created_at)
""",
)
def schema_statements_for_dsn(dsn: str) -> tuple[str, ...]:
if dsn.startswith("sqlite:///"):
return SQLITE_SCHEMA_STATEMENTS
return POSTGRES_SCHEMA_STATEMENTS
def setup_database(dsn: str) -> None:
statements = schema_statements_for_dsn(dsn)
if dsn.startswith("sqlite:///"):
sqlite_path = dsn.removeprefix("sqlite:///")
with closing(sqlite3.connect(sqlite_path)) as connection:
for statement in statements:
connection.execute(statement)
connection.commit()
return
import psycopg
with psycopg.connect(dsn) as connection:
with connection.cursor() as cursor:
for statement in statements:
cursor.execute(statement)
@@ -0,0 +1,20 @@
from __future__ import annotations
import os
from src.application.seed_data import seed_reference_data
from src.infrastructure.repositories import open_backend_repository
def main() -> None:
dsn = os.environ.get("DATABASE_URL") or os.environ.get("POSTGRES_DSN")
if not dsn:
raise RuntimeError("DATABASE_URL or POSTGRES_DSN must be set")
repository = open_backend_repository(dsn)
repository.setup()
seed_reference_data(repository)
if __name__ == "__main__":
main()
@@ -0,0 +1,86 @@
from __future__ import annotations
import os
import tempfile
from collections.abc import Callable, Iterable
from pathlib import Path
from fastapi import Depends, Header, HTTPException, status
from src.application.auth import get_current_user_by_email
from src.application.seed_data import seed_reference_data
from src.domain.contracts import CurrentUser, Role
from src.infrastructure.repositories import BackendRepository, open_backend_repository
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
UNAUTHORIZED_DETAIL = "Unauthorized"
FORBIDDEN_DETAIL = "Forbidden"
_runtime_repository: BackendRepository | None = None
_runtime_repository_dsn: str | None = None
_runtime_temp_dir: tempfile.TemporaryDirectory[str] | None = None
def get_repository() -> BackendRepository:
global _runtime_repository
global _runtime_repository_dsn
global _runtime_temp_dir
dsn = (
os.environ.get("DATABASE_URL")
or os.environ.get("POSTGRES_DSN")
or os.environ.get("PIPELINE_TEST_DATABASE_DSN")
)
if dsn is None:
if _runtime_temp_dir is None:
_runtime_temp_dir = tempfile.TemporaryDirectory()
sqlite_path = Path(_runtime_temp_dir.name) / "pipeline-demo-auth.db"
dsn = f"sqlite:///{sqlite_path}"
if _runtime_repository is not None and _runtime_repository_dsn == dsn:
return _runtime_repository
repository = open_backend_repository(dsn)
repository.setup()
seed_reference_data(repository)
_runtime_repository = repository
_runtime_repository_dsn = dsn
return repository
def get_current_user(
demo_user_email: str | None = Header(default=None, alias=DEMO_USER_EMAIL_HEADER),
repository: BackendRepository = Depends(get_repository),
) -> CurrentUser:
if not demo_user_email:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=UNAUTHORIZED_DETAIL,
)
try:
return get_current_user_by_email(repository, demo_user_email)
except LookupError as error:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=UNAUTHORIZED_DETAIL,
) from error
def require_roles(
allowed_roles: Iterable[Role],
) -> Callable[[CurrentUser], CurrentUser]:
allowed = frozenset(allowed_roles)
def dependency(
current_user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
if current_user.role not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=FORBIDDEN_DETAIL,
)
return current_user
return dependency
+39 -1
View File
@@ -1,10 +1,29 @@
from typing import Any
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from fastapi.responses import JSONResponse 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.agent_jobs import (
internal_router as internal_agent_jobs_router,
)
from src.presentation.routes.agent_jobs import router as agent_jobs_router
from src.presentation.routes.articles import router as articles_router
from src.presentation.routes.auth import router as auth_router
from src.presentation.routes.boundary_questions import router as boundary_questions_router
from src.presentation.routes.plans import router as plans_router
from src.presentation.routes.sites import router as sites_router
app = FastAPI(title="AI Content Pipeline Backend") app = FastAPI(title="AI Content Pipeline Backend")
app.include_router(auth_router)
app.include_router(articles_router)
app.include_router(boundary_questions_router)
app.include_router(plans_router)
app.include_router(agent_jobs_router)
app.include_router(internal_agent_jobs_router)
app.include_router(sites_router)
@app.get("/health") @app.get("/health")
@@ -14,6 +33,8 @@ def health() -> dict[str, str]:
@app.get("/health/dependencies") @app.get("/health/dependencies")
def dependency_health() -> JSONResponse: def dependency_health() -> JSONResponse:
from src.infrastructure.dependencies import check_dependencies
dependencies = check_dependencies() dependencies = check_dependencies()
is_ok = all(status == "ok" for status in dependencies.values()) is_ok = all(status == "ok" for status in dependencies.values())
@@ -25,3 +46,20 @@ def dependency_health() -> JSONResponse:
"dependencies": dependencies, "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,133 @@
from __future__ import annotations
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Body, Depends, HTTPException, Response, status
from pydantic import BaseModel, Field
from src.application.agent_jobs import (
cancel_agent_job,
claim_next_agent_job,
complete_agent_job,
create_test_codex_job,
get_agent_job,
list_agent_jobs,
retry_agent_job,
)
from src.domain.auth import ADMIN_ROLES
from src.domain.contracts import (
AgentJobListResponse,
AgentJobResponse,
AgentJobTestCodexRequest,
CurrentUser,
)
from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles
router = APIRouter(prefix="/api", tags=["agent-jobs"])
internal_router = APIRouter(prefix="/internal/agent-jobs", include_in_schema=False)
class AgentJobCompletionRequest(BaseModel):
workspace_path: str | None = None
stdout: str = ""
stderr: str = ""
exit_code: int | None = None
duration_ms: int | None = Field(default=None, ge=0)
output: dict[str, Any] = Field(default_factory=dict)
@router.post(
"/agent-jobs/test-codex",
response_model=AgentJobResponse,
status_code=status.HTTP_201_CREATED,
)
def post_test_codex_job(
request: AgentJobTestCodexRequest = Body(
default_factory=AgentJobTestCodexRequest
),
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AgentJobResponse:
return create_test_codex_job(repository, request)
@router.get("/agent-jobs", response_model=AgentJobListResponse)
def get_agent_jobs(
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AgentJobListResponse:
return list_agent_jobs(repository)
@router.get("/agent-jobs/{job_id}", response_model=AgentJobResponse)
def get_agent_job_route(
job_id: UUID,
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AgentJobResponse:
try:
return get_agent_job(repository, job_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/agent-jobs/{job_id}/retry",
response_model=AgentJobResponse,
status_code=status.HTTP_201_CREATED,
)
def post_retry_agent_job(
job_id: UUID,
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AgentJobResponse:
try:
return retry_agent_job(repository, job_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post("/agent-jobs/{job_id}/cancel", response_model=AgentJobResponse)
def post_cancel_agent_job(
job_id: UUID,
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AgentJobResponse:
try:
return cancel_agent_job(repository, job_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@internal_router.post("/claim", response_model=None)
def post_claim_agent_job(
repository: BackendRepository = Depends(get_repository),
):
claimed = claim_next_agent_job(repository)
if claimed is None:
return Response(status_code=status.HTTP_204_NO_CONTENT)
return claimed
@internal_router.post("/{job_id}/complete", response_model=AgentJobResponse)
def post_complete_agent_job(
job_id: UUID,
request: AgentJobCompletionRequest,
repository: BackendRepository = Depends(get_repository),
) -> AgentJobResponse:
try:
return complete_agent_job(
repository,
job_id=job_id,
workspace_path=request.workspace_path,
stdout=request.stdout,
stderr=request.stderr,
exit_code=request.exit_code,
duration_ms=request.duration_ms,
output=request.output,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@@ -0,0 +1,73 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from src.application.articles import (
create_article,
get_article_detail,
list_articles,
)
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
ArticleCreateRequest,
ArticleCreateResponse,
ArticleDetailResponse,
ArticleListResponse,
CurrentUser,
)
from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles
router = APIRouter(prefix="/api", tags=["articles"])
@router.post(
"/articles",
response_model=ArticleCreateResponse,
status_code=status.HTTP_201_CREATED,
)
def post_article(
request: ArticleCreateRequest,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ArticleCreateResponse:
try:
return create_article(
repository,
request=request,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid target_site_id",
) from error
@router.get("/articles", response_model=ArticleListResponse)
def list_articles_route(
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ArticleListResponse:
return list_articles(repository)
@router.get(
"/articles/{article_id}",
response_model=ArticleDetailResponse,
)
def get_article(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ArticleDetailResponse:
try:
return get_article_detail(repository=repository, article_id=article_id)
except LookupError as error:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Not Found",
) from error
@@ -0,0 +1,16 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from src.domain.contracts import CurrentUser, CurrentUserResponse
from src.presentation.dependencies import get_current_user
router = APIRouter(prefix="/api", tags=["auth"])
@router.get("/me", response_model=CurrentUserResponse)
def get_me(
current_user: CurrentUser = Depends(get_current_user),
) -> CurrentUserResponse:
return CurrentUserResponse(user=current_user)
@@ -0,0 +1,146 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from src.application.boundary_questions import (
create_plan_generation_job,
generate_boundary_questions,
list_boundary_questions,
submit_boundary_questions,
update_boundary_question,
)
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
AgentJobListResponse,
ArticleCreateResponse,
BoundaryQuestionListResponse,
BoundaryQuestionResponse,
BoundaryQuestionUpdateRequest,
CurrentUser,
)
from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles
router = APIRouter(prefix="/api", tags=["boundary-questions"])
@router.post(
"/articles/{article_id}/boundary-questions/generate",
response_model=BoundaryQuestionListResponse,
status_code=status.HTTP_201_CREATED,
)
def post_generate_boundary_questions(
article_id: UUID,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> BoundaryQuestionListResponse:
try:
return generate_boundary_questions(
repository,
article_id=article_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Not Found",
) from error
@router.get(
"/articles/{article_id}/boundary-questions",
response_model=BoundaryQuestionListResponse,
)
def get_boundary_questions(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> BoundaryQuestionListResponse:
try:
return list_boundary_questions(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Not Found",
) from error
@router.patch(
"/articles/{article_id}/boundary-questions/{question_id}",
response_model=BoundaryQuestionResponse,
)
def patch_boundary_question(
article_id: UUID,
question_id: UUID,
request: BoundaryQuestionUpdateRequest,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> BoundaryQuestionResponse:
try:
return update_boundary_question(
repository,
article_id=article_id,
question_id=question_id,
request=request,
)
except LookupError as error:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Not Found",
) from error
@router.post(
"/articles/{article_id}/boundary-questions/submit",
response_model=ArticleCreateResponse,
)
def post_submit_boundary_questions(
article_id: UUID,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ArticleCreateResponse:
try:
article = submit_boundary_questions(
repository,
article_id=article_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Not Found",
) from error
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(error),
) from error
return ArticleCreateResponse(article=article)
@router.post(
"/articles/{article_id}/plans/generate",
response_model=AgentJobListResponse,
status_code=status.HTTP_202_ACCEPTED,
)
def post_generate_plan(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AgentJobListResponse:
try:
return create_plan_generation_job(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Not Found",
) from error
except PermissionError as error:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(error),
) from error
@@ -0,0 +1,180 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from src.application.plans import (
approve_plan,
edit_plan,
generate_plan,
get_plan,
list_plans,
request_plan_revision,
)
from src.application.research import list_research_runs, start_research_run
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
CurrentUser,
PlanListResponse,
PlanResponse,
PlanRevisionRequest,
PlanUpdateRequest,
ResearchListResponse,
ResearchStartResponse,
ReviewActionResponse,
)
from src.infrastructure.object_storage import open_object_storage_client
from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles
router = APIRouter(prefix="/api", tags=["plans"])
@router.post(
"/articles/{article_id}/plan/generate",
response_model=PlanResponse,
status_code=status.HTTP_201_CREATED,
)
def post_generate_plan(
article_id: UUID,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> PlanResponse:
try:
return generate_plan(repository, article_id=article_id, current_user=current_user)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except PermissionError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.get("/articles/{article_id}/plans", response_model=PlanListResponse)
def get_plans(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> PlanListResponse:
try:
return list_plans(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.get("/articles/{article_id}/plans/{plan_id}", response_model=PlanResponse)
def get_plan_route(
article_id: UUID,
plan_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> PlanResponse:
try:
return get_plan(repository, article_id=article_id, plan_id=plan_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.patch(
"/articles/{article_id}/plans/{plan_id}",
response_model=PlanResponse,
status_code=status.HTTP_201_CREATED,
)
def patch_plan(
article_id: UUID,
plan_id: UUID,
request: PlanUpdateRequest,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> PlanResponse:
try:
return edit_plan(
repository,
article_id=article_id,
plan_id=plan_id,
request=request,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/plans/{plan_id}/approve",
response_model=ReviewActionResponse,
)
def post_approve_plan(
article_id: UUID,
plan_id: UUID,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ReviewActionResponse:
try:
return approve_plan(
repository,
article_id=article_id,
plan_id=plan_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/plans/{plan_id}/request-revision",
response_model=ReviewActionResponse,
)
def post_request_plan_revision(
article_id: UUID,
plan_id: UUID,
request: PlanRevisionRequest,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ReviewActionResponse:
try:
return request_plan_revision(
repository,
article_id=article_id,
plan_id=plan_id,
request=request,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/research/start",
response_model=ResearchStartResponse,
status_code=status.HTTP_201_CREATED,
)
def post_start_research(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ResearchStartResponse:
try:
return start_research_run(
repository,
article_id=article_id,
object_storage=open_object_storage_client(),
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except PermissionError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.get(
"/articles/{article_id}/research",
response_model=ResearchListResponse,
)
def get_research(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ResearchListResponse:
try:
return list_research_runs(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@@ -0,0 +1,156 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from src.application.site_config import (
activate_script_config_version,
create_script_config_version,
create_target_site,
get_target_site,
list_script_config_versions,
rollback_script_config_version,
update_target_site,
)
from src.domain.auth import ADMIN_ROLES, EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
CurrentUser,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse,
ScriptConfigVersionResponse,
TargetSiteConfigCreateRequest,
TargetSiteConfigResponse,
TargetSiteConfigUpdateRequest,
)
from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles
router = APIRouter(prefix="/api", tags=["sites"])
@router.get("/sites", response_model=list[TargetSiteConfigResponse])
def list_sites(
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> list[TargetSiteConfigResponse]:
return [TargetSiteConfigResponse(site=site) for site in repository.target_sites.list()]
@router.get("/sites/{site_id}", response_model=TargetSiteConfigResponse)
def get_site(
site_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> TargetSiteConfigResponse:
try:
return get_target_site(repository, site_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/sites",
response_model=TargetSiteConfigResponse,
status_code=status.HTTP_201_CREATED,
)
def post_site(
request: TargetSiteConfigCreateRequest,
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> TargetSiteConfigResponse:
return create_target_site(repository, request)
@router.patch("/sites/{site_id}", response_model=TargetSiteConfigResponse)
def patch_site(
site_id: UUID,
request: TargetSiteConfigUpdateRequest,
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> TargetSiteConfigResponse:
try:
return update_target_site(repository, site_id, request)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.get(
"/sites/{site_id}/publishing-config/versions",
response_model=ScriptConfigVersionListResponse,
)
def get_script_config_versions(
site_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ScriptConfigVersionListResponse:
try:
versions = list_script_config_versions(repository, site_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
return ScriptConfigVersionListResponse(versions=versions)
@router.post(
"/sites/{site_id}/publishing-config/versions",
response_model=ScriptConfigVersionResponse,
status_code=status.HTTP_201_CREATED,
)
def post_script_config_version(
site_id: UUID,
request: ScriptConfigVersionCreateRequest,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ScriptConfigVersionResponse:
try:
return create_script_config_version(
repository,
site_id=site_id,
current_user=current_user,
request=request,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/sites/{site_id}/publishing-config/versions/{version_id}/activate",
response_model=ScriptConfigVersionResponse,
)
def post_activate_script_config_version(
site_id: UUID,
version_id: UUID,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ScriptConfigVersionResponse:
try:
return activate_script_config_version(
repository,
site_id=site_id,
version_id=version_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/sites/{site_id}/publishing-config/versions/{version_id}/rollback",
response_model=ScriptConfigVersionResponse,
)
def post_rollback_script_config_version(
site_id: UUID,
version_id: UUID,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ScriptConfigVersionResponse:
try:
return rollback_script_config_version(
repository,
site_id=site_id,
version_id=version_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -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()
@@ -0,0 +1 @@
@@ -0,0 +1,187 @@
from __future__ import annotations
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
from typing import Any
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_USER_EMAIL_HEADER = "X-Demo-User-Email"
class AgentJobQueuePublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
self.workspace_root = Path(self.tmp_dir.name) / "runner-workspaces"
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'agent-jobs.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_admin_enqueues_and_fake_runner_completes_test_codex_job(self) -> None:
create_response = self.client.post(
"/api/agent-jobs/test-codex",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(201, create_response.status_code, create_response.text)
created = create_response.json()["job"]
self.assertEqual("TEST_CODEX", created["job_type"])
self.assertEqual("QUEUED", created["status"])
self.assertEqual(1, created["attempt"])
self._run_fake_runner_once()
get_response = self.client.get(
f"/api/agent-jobs/{created['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
list_response = self.client.get(
"/api/agent-jobs",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(200, get_response.status_code, get_response.text)
completed = get_response.json()["job"]
self.assertEqual("SUCCEEDED", completed["status"])
self.assertTrue(completed["workspace_path"])
self.assertIn("fake Codex runner completed", completed["stdout"])
self.assertEqual("", completed["stderr"])
self.assertEqual(0, completed["exit_code"])
self.assertGreaterEqual(completed["duration_ms"], 0)
self.assertEqual([{"path": "outputs/result.json", "content_hash": None}], completed["output_files"])
self.assertEqual(200, list_response.status_code, list_response.text)
self.assertIn(
created["id"],
[job["id"] for job in list_response.json()["jobs"]],
)
self.assertTrue((Path(completed["workspace_path"]) / "outputs" / "result.json").is_file())
def test_retry_creates_traceable_child_attempt(self) -> None:
failed = self.client.post(
"/api/agent-jobs/test-codex",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json={"fake_result": "invalid_schema"},
).json()["job"]
self._run_fake_runner_once()
failed_response = self.client.get(
f"/api/agent-jobs/{failed['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual("FAILED", failed_response.json()["job"]["status"])
self.assertEqual(
"FAILED_SCHEMA_VALIDATION",
failed_response.json()["job"]["error_category"],
)
retry_response = self.client.post(
f"/api/agent-jobs/{failed['id']}/retry",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(201, retry_response.status_code, retry_response.text)
retry = retry_response.json()["job"]
self.assertEqual("QUEUED", retry["status"])
self.assertEqual(failed["id"], retry["parent_job_id"])
self.assertEqual(2, retry["attempt"])
def test_cancelled_job_is_not_claimed_or_completed_by_runner(self) -> None:
created = self.client.post(
"/api/agent-jobs/test-codex",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
).json()["job"]
cancel_response = self.client.post(
f"/api/agent-jobs/{created['id']}/cancel",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(200, cancel_response.status_code, cancel_response.text)
self.assertEqual("CANCELLED", cancel_response.json()["job"]["status"])
self._run_fake_runner_once()
get_response = self.client.get(
f"/api/agent-jobs/{created['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
job = get_response.json()["job"]
self.assertEqual("CANCELLED", job["status"])
self.assertEqual([], job["output_files"])
self.assertIsNone(job["workspace_path"])
def _run_fake_runner_once(self) -> None:
claim_response = self.client.post("/internal/agent-jobs/claim")
if claim_response.status_code == 204:
return
self.assertEqual(200, claim_response.status_code, claim_response.text)
job = claim_response.json()["job"]
workspace_path = self._write_fake_workspace(job)
fake_output: dict[str, Any]
if job["agent_profile"] == "fake-invalid-schema":
fake_output = {"status": "NOT_A_STATUS"}
else:
fake_output = {
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/result.json"}],
"payload": {"job_id": job["id"], "mode": "fake"},
}
complete_response = self.client.post(
f"/internal/agent-jobs/{job['id']}/complete",
json={
"workspace_path": str(workspace_path),
"stdout": "fake Codex runner completed\n",
"stderr": "",
"exit_code": 0,
"duration_ms": 1,
"output": fake_output,
},
)
self.assertEqual(200, complete_response.status_code, complete_response.text)
def _write_fake_workspace(self, job: dict[str, Any]) -> Path:
workspace_path = self.workspace_root / job["id"]
if workspace_path.exists():
shutil.rmtree(workspace_path)
(workspace_path / "inputs").mkdir(parents=True)
(workspace_path / "logs").mkdir()
(workspace_path / "outputs").mkdir()
(workspace_path / "inputs" / "job.json").write_text(str(job), encoding="utf-8")
(workspace_path / "logs" / "stdout.log").write_text(
"fake Codex runner completed\n",
encoding="utf-8",
)
(workspace_path / "logs" / "stderr.log").write_text("", encoding="utf-8")
(workspace_path / "outputs" / "result.json").write_text(
'{"status":"ok"}\n',
encoding="utf-8",
)
return workspace_path
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,217 @@
from __future__ import annotations
import sys
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.presentation.main import app # noqa: E402
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class ArticleIntakePublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.client = TestClient(app)
def test_editor_creates_article_and_reads_dashboard_detail_timeline(self) -> None:
headers = {DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}
editor_response = self.client.get("/api/me", headers=headers)
self.assertEqual(200, editor_response.status_code, editor_response.text)
editor_user_id = editor_response.json()["user"]["id"]
sites_response = self.client.get("/api/sites", headers=headers)
self.assertEqual(200, sites_response.status_code, sites_response.text)
target_site = sites_response.json()[0]["site"]
brief_description = (
"Create an editorial control guide for AI content intake "
f"{uuid4()}."
)
create_response = self.client.post(
"/api/articles",
headers=headers,
json={
"target_site_id": target_site["id"],
"brief_description": brief_description,
"working_title": "AI Content Intake Controls",
"content_type": "longform_guide",
"primary_keyword": "AI content intake",
},
)
self.assertEqual(201, create_response.status_code, create_response.text)
created_article = create_response.json()["article"]
self.assertEqual("ARTICLE_BRIEF_CREATED", created_article["status"])
self.assertEqual(target_site["id"], created_article["target_site_id"])
self.assertEqual(brief_description, created_article["brief_description"])
article_id = created_article["id"]
list_response = self.client.get("/api/articles", headers=headers)
detail_response = self.client.get(f"/api/articles/{article_id}", headers=headers)
failures: list[str] = []
if list_response.status_code != 200:
failures.append(
"GET /api/articles should return the persisted dashboard list; "
f"got {list_response.status_code}: {list_response.text}"
)
else:
article = self._find_article(list_response.json(), article_id)
if article is None:
failures.append(
"Created article is missing from GET /api/articles dashboard list"
)
else:
self._expect_equal(
failures,
"dashboard article status",
"ARTICLE_BRIEF_CREATED",
article.get("status"),
)
self._expect_equal(
failures,
"dashboard article target_site_id",
target_site["id"],
article.get("target_site_id"),
)
if detail_response.status_code != 200:
failures.append(
"GET /api/articles/{article_id} should return the persisted detail "
f"shell; got {detail_response.status_code}: {detail_response.text}"
)
else:
detail = detail_response.json()
if not isinstance(detail, dict):
failures.append("Article detail should return a JSON object")
detail = {}
detail_article = detail.get("article", {})
self._expect_equal(
failures,
"detail article id",
article_id,
detail_article.get("id"),
)
self._expect_equal(
failures,
"detail article status",
"ARTICLE_BRIEF_CREATED",
detail_article.get("status"),
)
detail_target_site = detail.get("target_site")
if not isinstance(detail_target_site, dict):
failures.append("Article detail should include target_site summary")
else:
self._expect_equal(
failures,
"detail target_site id",
target_site["id"],
detail_target_site.get("id"),
)
self._expect_equal(
failures,
"detail target_site slug",
target_site["slug"],
detail_target_site.get("slug"),
)
workflow_events = detail.get("workflow_events")
if not isinstance(workflow_events, list):
failures.append("Article detail should include workflow_events timeline")
else:
created_events = [
event
for event in workflow_events
if event.get("event_type") == "ARTICLE_CREATED"
]
if not created_events:
failures.append(
"Article detail workflow_events should contain ARTICLE_CREATED"
)
else:
created_event = created_events[0]
self._expect_equal(
failures,
"ARTICLE_CREATED article_id",
article_id,
created_event.get("article_id"),
)
self._expect_equal(
failures,
"ARTICLE_CREATED to_status",
"ARTICLE_BRIEF_CREATED",
created_event.get("to_status"),
)
self._expect_equal(
failures,
"ARTICLE_CREATED actor_user_id",
editor_user_id,
created_event.get("actor_user_id"),
)
if not created_event.get("created_at"):
failures.append("ARTICLE_CREATED should include created_at")
self.assertEqual([], failures)
def test_editor_cannot_create_article_with_unknown_target_site(self) -> None:
response = self.client.post(
"/api/articles",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"target_site_id": "00000000-0000-0000-0000-000000000000",
"brief_description": "Intake target-site guardrail check.",
"content_type": "longform_guide",
"primary_keyword": "integration",
},
)
self.assertEqual(400, response.status_code, response.text)
self.assertEqual("Invalid target_site_id", response.json()["detail"])
def _find_article(
self,
response_body: Any,
article_id: str,
) -> dict[str, Any] | None:
if not isinstance(response_body, dict):
return None
articles = response_body.get("articles")
if not isinstance(articles, list):
return None
return next(
(
article
for article in articles
if isinstance(article, dict) and article.get("id") == article_id
),
None,
)
def _expect_equal(
self,
failures: list[str],
label: str,
expected: object,
actual: object,
) -> None:
if actual != expected:
failures.append(f"{label}: expected {expected!r}, got {actual!r}")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,381 @@
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from fastapi.testclient import TestClient
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
from src.presentation.main import app # noqa: E402
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_ADMIN_EMAIL = "admin@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class AuthAuthorizationPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.client = TestClient(app)
def test_me_returns_selected_demo_user_and_role(self) -> None:
response = self.client.get(
"/api/me",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(200, response.status_code, response.text)
self.assertEqual(DEMO_ADMIN_EMAIL, response.json()["user"]["email"])
self.assertEqual("ADMIN", response.json()["user"]["role"])
def test_missing_and_unknown_demo_users_are_rejected_consistently(self) -> None:
missing_response = self.client.get("/api/me")
unknown_response = self.client.get(
"/api/me",
headers={DEMO_USER_EMAIL_HEADER: "missing@example.com"},
)
self.assertEqual(401, missing_response.status_code, missing_response.text)
self.assertEqual(401, unknown_response.status_code, unknown_response.text)
self.assertEqual({"detail": "Unauthorized"}, missing_response.json())
self.assertEqual({"detail": "Unauthorized"}, unknown_response.json())
def test_editor_cannot_create_target_site_config(self) -> None:
response = self.client.post(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json=self._site_payload("editorial_ops_blog"),
)
self.assertEqual(403, response.status_code, response.text)
self.assertEqual({"detail": "Forbidden"}, response.json())
def test_admin_can_create_and_update_target_site_config(self) -> None:
create_response = self.client.post(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json=self._site_payload("admin_ops_blog"),
)
self.assertEqual(201, create_response.status_code, create_response.text)
site = create_response.json()["site"]
self.assertEqual("admin_ops_blog", site["slug"])
update_response = self.client.patch(
f"/api/sites/{site['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json={"brand_voice": "Updated practical editorial operations guidance."},
)
self.assertEqual(200, update_response.status_code, update_response.text)
self.assertEqual(
"Updated practical editorial operations guidance.",
update_response.json()["site"]["brand_voice"],
)
def test_editor_cannot_update_site_or_mutate_script_versions(self) -> None:
create_response = self.client.post(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json=self._site_payload("script_editor_denied_blog"),
)
self.assertEqual(201, create_response.status_code, create_response.text)
site_id = create_response.json()["site"]["id"]
version_response = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json=self._script_version_payload(),
)
self.assertEqual(201, version_response.status_code, version_response.text)
version_id = version_response.json()["version"]["id"]
site_update_response = self.client.patch(
f"/api/sites/{site_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"brand_voice": "Editor should not mutate Admin config."},
)
version_create_response = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json=self._script_version_payload(),
)
version_activate_response = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions/{version_id}/activate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(403, site_update_response.status_code, site_update_response.text)
self.assertEqual(
403,
version_create_response.status_code,
version_create_response.text,
)
self.assertEqual(
403,
version_activate_response.status_code,
version_activate_response.text,
)
def test_editor_cannot_rollback_script_config_version(self) -> None:
site_response = self.client.post(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json=self._site_payload("rollback_denied_blog"),
)
self.assertEqual(201, site_response.status_code, site_response.text)
site_id = site_response.json()["site"]["id"]
first_version = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json=self._script_version_payload(),
)
self.assertEqual(201, first_version.status_code, first_version.text)
version_id = first_version.json()["version"]["id"]
activate_response = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions/{version_id}/activate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
rollback_response = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions/{version_id}/rollback",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(403, activate_response.status_code, activate_response.text)
self.assertEqual(403, rollback_response.status_code, rollback_response.text)
def test_admin_can_create_and_activate_script_config_version(self) -> None:
site_response = self.client.post(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json=self._site_payload("script_admin_blog"),
)
self.assertEqual(201, site_response.status_code, site_response.text)
site_id = site_response.json()["site"]["id"]
create_response = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json=self._script_version_payload(),
)
self.assertEqual(201, create_response.status_code, create_response.text)
self.assertEqual("DRAFT", create_response.json()["version"]["status"])
version_id = create_response.json()["version"]["id"]
activate_response = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions/{version_id}/activate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(200, activate_response.status_code, activate_response.text)
self.assertEqual("ACTIVE", activate_response.json()["version"]["status"])
def test_admin_can_get_site_by_id(self) -> None:
site_response = self.client.post(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json=self._site_payload("admin_get_site_by_id_blog"),
)
self.assertEqual(201, site_response.status_code, site_response.text)
site_id = site_response.json()["site"]["id"]
get_response = self.client.get(
f"/api/sites/{site_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, get_response.status_code, get_response.text)
self.assertEqual(site_id, get_response.json()["site"]["id"])
def test_admin_can_create_version_with_rollback_target(self) -> None:
site_response = self.client.post(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json=self._site_payload("rollback_target_blog"),
)
self.assertEqual(201, site_response.status_code, site_response.text)
site_id = site_response.json()["site"]["id"]
baseline = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json=self._script_version_payload(diff={"summary": "baseline"}),
)
self.assertEqual(201, baseline.status_code, baseline.text)
baseline_id = baseline.json()["version"]["id"]
create_response = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json=self._script_version_payload(
diff={"summary": "rollback-aware"},
rollback_target_version_id=baseline_id,
activate=True,
),
)
self.assertEqual(201, create_response.status_code, create_response.text)
created = create_response.json()["version"]
self.assertEqual("ACTIVE", created["status"])
self.assertEqual("rollback-aware", created["diff"].get("summary"))
self.assertEqual(baseline_id, created["rollback_target_version_id"])
versions_response = self.client.get(
f"/api/sites/{site_id}/publishing-config/versions",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, versions_response.status_code, versions_response.text)
self.assertEqual(2, len(versions_response.json()["versions"]))
def test_admin_can_rollback_script_config_version(self) -> None:
site_response = self.client.post(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json=self._site_payload("rollback_action_blog"),
)
self.assertEqual(201, site_response.status_code, site_response.text)
site_id = site_response.json()["site"]["id"]
active = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json=self._script_version_payload(
diff={"summary": "initial active"},
activate=True,
),
)
self.assertEqual(201, active.status_code, active.text)
active_id = active.json()["version"]["id"]
next_version = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json=self._script_version_payload(diff={"summary": "next version"}),
)
self.assertEqual(201, next_version.status_code, next_version.text)
next_id = next_version.json()["version"]["id"]
self.client.post(
f"/api/sites/{site_id}/publishing-config/versions/{next_id}/activate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
rollback_response = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions/{active_id}/rollback",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(200, rollback_response.status_code, rollback_response.text)
self.assertEqual(active_id, rollback_response.json()["version"]["id"])
self.assertEqual(
"ACTIVE",
rollback_response.json()["version"]["status"],
)
self.assertEqual(
next_id,
rollback_response.json()["version"]["rollback_target_version_id"],
)
get_response = self.client.get(
f"/api/sites/{site_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, get_response.status_code, get_response.text)
self.assertEqual(
active_id,
get_response.json()["site"]["active_script_config_version_id"],
)
def test_editor_can_create_articles_and_approve_plan_review(self) -> None:
sites_response = self.client.get(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, sites_response.status_code, sites_response.text)
target_site_id = sites_response.json()[0]["site"]["id"]
article_response = self.client.post(
"/api/articles",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"target_site_id": target_site_id,
"brief_description": "Write about editorial AI workflow controls.",
"working_title": "Editorial AI Workflow Controls",
},
)
self.assertEqual(201, article_response.status_code, article_response.text)
self.assertEqual(
"ARTICLE_BRIEF_CREATED",
article_response.json()["article"]["status"],
)
article_id = article_response.json()["article"]["id"]
review_response = self.client.post(
f"/api/articles/{article_id}/plans/00000000-0000-0000-0000-000000000123/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, review_response.status_code, review_response.text)
self.assertEqual("PLAN", review_response.json()["review"]["review_type"])
self.assertEqual("APPROVED", review_response.json()["review"]["status"])
def test_unauthorized_requests_are_rejected_for_protected_mutations(self) -> None:
response = self.client.post(
"/api/articles",
json={
"target_site_id": "00000000-0000-0000-0000-000000000001",
"brief_description": "No user selected.",
},
)
self.assertEqual(401, response.status_code, response.text)
self.assertEqual({"detail": "Unauthorized"}, response.json())
def _site_payload(self, slug: str) -> dict[str, object]:
return {
"name": "Editorial Ops Blog",
"slug": slug,
"publishing_type": "git_next",
"default_language": "en",
"brand_voice": "Practical editorial operations guidance.",
"audience": "Editorial teams running AI-assisted workflows.",
"seo_rules": {"primary_keyword_required": True},
"visual_rules": {"hero_style": "product editorial"},
"source_rules": {"minimum_sources": 3},
"publishing_rules": {
"repository_url": "git@github.com:example/editorial-ops.git",
"production_branch": "main",
"content_format": "mdx",
"content_path_template": "content/articles/{slug}.mdx",
"asset_path_template": "public/articles/{slug}/{filename}",
"frontmatter_mapping": {"title": "title"},
"dry_run_renderer": "next-mdx",
},
}
def _script_version_payload(
self,
diff: dict[str, object] | None = None,
rollback_target_version_id: str | None = None,
activate: bool = False,
) -> dict[str, object]:
payload = {
"publishing_yaml": "target: editorial_ops\nrepository:\n branch: main\n",
"transform_script": "export function transformArticle(article) { return article; }\n",
"diff": diff or {"summary": "Task 004 authorization fixture."},
}
if rollback_target_version_id is not None:
payload["rollback_target_version_id"] = rollback_target_version_id
if activate:
payload["activate"] = True
return payload
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,196 @@
from __future__ import annotations
import sys
import tempfile
import unittest
from pathlib import Path
from typing import Any
from fastapi.testclient import TestClient
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
from src.application.seed_data import seed_reference_data # noqa: E402
from src.infrastructure.repositories import open_backend_repository # noqa: E402
from src.presentation.dependencies import get_repository # noqa: E402
from src.presentation.main import app # noqa: E402
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class BoundaryQuestionsPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'boundary-questions.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_required_unanswered_questions_block_submission_then_submit_updates_workflow(
self,
) -> None:
article_id = self._create_article()
blocked_plan_response = self.client.post(
f"/api/articles/{article_id}/plans/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(409, blocked_plan_response.status_code, blocked_plan_response.text)
generate_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, generate_response.status_code, generate_response.text)
generated = generate_response.json()
questions = generated["questions"]
self.assertGreaterEqual(len(questions), 5)
self.assertLessEqual(len(questions), 10)
self.assertEqual("BOUNDARY_QUESTIONS", generated["job"]["job_type"])
self.assertEqual("SUCCEEDED", generated["job"]["status"])
categories = {question["category"] for question in questions}
for category in (
"audience",
"purpose",
"reader_outcome",
"depth",
"tone",
"excluded_topics",
"primary_keyword",
"competitor_angle",
"evidence_standard",
"visual_expectations",
):
self.assertIn(category, categories)
required_question = next(
question for question in questions if question["is_required"]
)
optional_question = next(
question for question in questions if not question["is_required"]
)
patch_response = self.client.patch(
f"/api/articles/{article_id}/boundary-questions/{required_question['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"answer": "Senior editors evaluating automated SEO content."},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
self.assertEqual(
"Senior editors evaluating automated SEO content.",
patch_response.json()["question"]["answer"],
)
optional_patch_response = self.client.patch(
f"/api/articles/{article_id}/boundary-questions/{optional_question['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"answer": "No custom visual constraints."},
)
self.assertEqual(200, optional_patch_response.status_code, optional_patch_response.text)
submit_blocked_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/submit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(409, submit_blocked_response.status_code, submit_blocked_response.text)
self.assertIn("required", submit_blocked_response.json()["detail"])
list_response = self.client.get(
f"/api/articles/{article_id}/boundary-questions",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, list_response.status_code, list_response.text)
saved_required = self._find_question(
list_response.json()["questions"],
required_question["id"],
)
self.assertEqual(
"Senior editors evaluating automated SEO content.",
saved_required["answer"],
)
for question in list_response.json()["questions"]:
if question["is_required"] and not question["answer"]:
answer_response = self.client.patch(
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"answer": f"Accepted answer for {question['category']}."},
)
self.assertEqual(200, answer_response.status_code, answer_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)
self.assertEqual(
"BOUNDARY_ANSWERS_SUBMITTED",
submit_response.json()["article"]["status"],
)
detail_response = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, detail_response.status_code, detail_response.text)
detail = detail_response.json()
self.assertEqual("BOUNDARY_ANSWERS_SUBMITTED", detail["article"]["status"])
self.assertTrue(
any(
event["event_type"] == "BOUNDARY_ANSWERS_SUBMITTED"
for event in detail["workflow_events"]
)
)
allowed_plan_response = self.client.post(
f"/api/articles/{article_id}/plans/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(202, allowed_plan_response.status_code, allowed_plan_response.text)
def _create_article(self) -> str:
site_response = self.client.get(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, site_response.status_code, site_response.text)
target_site = site_response.json()[0]["site"]
response = self.client.post(
"/api/articles",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"target_site_id": target_site["id"],
"brief_description": "Build a precise comparison of AI content systems.",
"working_title": "AI Content Systems",
"content_type": "longform_guide",
"primary_keyword": "AI content systems",
},
)
self.assertEqual(201, response.status_code, response.text)
return response.json()["article"]["id"]
def _find_question(
self,
questions: list[dict[str, Any]],
question_id: str,
) -> dict[str, Any]:
for question in questions:
if question["id"] == question_id:
return question
self.fail(f"Question not found: {question_id}")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,174 @@
from __future__ import annotations
import sys
import tempfile
import unittest
from pathlib import Path
from fastapi.testclient import TestClient
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
from src.application.seed_data import seed_reference_data # noqa: E402
from src.infrastructure.repositories import open_backend_repository # noqa: E402
from src.presentation.dependencies import get_repository # noqa: E402
from src.presentation.main import app # noqa: E402
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class PlanGenerationReviewPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'plans.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_research_start_is_blocked_until_plan_approval(self) -> None:
article_id = self._create_article_with_boundary_answers()
blocked_research_response = self.client.post(
f"/api/articles/{article_id}/research/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(409, blocked_research_response.status_code, blocked_research_response.text)
generate_response = self.client.post(
f"/api/articles/{article_id}/plan/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, generate_response.status_code, generate_response.text)
plan = generate_response.json()["plan"]
self.assertEqual(1, plan["version"])
self.assertEqual("PENDING_REVIEW", plan["status"])
self.assertGreaterEqual(len(plan["sections"]), 4)
self.assertTrue(plan["recommended_title"])
self.assertTrue(plan["reader_persona"])
self.assertTrue(plan["evidence_needs"])
self.assertTrue(plan["visual_needs"])
for section in plan["sections"]:
self.assertTrue(section["purpose"])
self.assertTrue(section["key_points"])
self.assertTrue(section["evidence_needs"])
edit_response = self.client.patch(
f"/api/articles/{article_id}/plans/{plan['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"recommended_title": "Edited plan title",
"tone": "Direct and evidence-led",
"audience": "Technical editors",
"seo_notes": ["Prioritize comparison intent"],
"sections": [
{
"heading": "Edited section",
"purpose": "Set the context",
"key_points": ["Point A", "Point B"],
"evidence_needs": ["Vendor docs"],
"target_word_count": 300,
}
],
},
)
self.assertEqual(201, edit_response.status_code, edit_response.text)
edited_plan = edit_response.json()["plan"]
self.assertEqual(2, edited_plan["version"])
self.assertEqual("Edited plan title", edited_plan["recommended_title"])
list_response = self.client.get(
f"/api/articles/{article_id}/plans",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, list_response.status_code, list_response.text)
self.assertEqual([1, 2], [item["version"] for item in list_response.json()["plans"]])
revision_response = self.client.post(
f"/api/articles/{article_id}/plans/{edited_plan['id']}/request-revision",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"notes": "Narrow the evidence standard."},
)
self.assertEqual(200, revision_response.status_code, revision_response.text)
self.assertEqual("CHANGES_REQUESTED", revision_response.json()["review"]["status"])
approve_response = self.client.post(
f"/api/articles/{article_id}/plans/{edited_plan['id']}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, approve_response.status_code, approve_response.text)
self.assertEqual("APPROVED", approve_response.json()["review"]["status"])
allowed_research_response = self.client.post(
f"/api/articles/{article_id}/research/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, allowed_research_response.status_code, allowed_research_response.text)
self.assertEqual("RESEARCH", allowed_research_response.json()["job"]["job_type"])
detail_response = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
events = detail_response.json()["workflow_events"]
approval_events = [
event for event in events if event["event_type"] == "PLAN_APPROVED"
]
self.assertEqual(1, len(approval_events))
self.assertEqual(2, approval_events[0]["payload"]["plan_version"])
def _create_article_with_boundary_answers(self) -> str:
site_response = self.client.get(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, site_response.status_code, site_response.text)
target_site = site_response.json()[0]["site"]
create_response = self.client.post(
"/api/articles",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"target_site_id": target_site["id"],
"brief_description": "Create an implementation plan for AI content governance.",
"working_title": "AI Content Governance",
"content_type": "longform_guide",
"primary_keyword": "AI content governance",
},
)
self.assertEqual(201, create_response.status_code, create_response.text)
article_id = create_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)
return article_id
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,43 @@
from __future__ import annotations
import os
import subprocess
import sys
import unittest
from importlib.util import find_spec
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[4]
BACKEND_ROOT = REPO_ROOT / "apps" / "backend"
sys.path.insert(0, str(BACKEND_ROOT))
class PostgresAlembicMigrationIntegrationTest(unittest.TestCase):
def test_alembic_upgrade_head_creates_schema_on_configured_postgres(self) -> None:
dsn = os.environ.get("PIPELINE_TEST_DATABASE_DSN")
if not dsn:
self.skipTest("PIPELINE_TEST_DATABASE_DSN is not set")
if not dsn.startswith(("postgresql://", "postgres://")):
self.skipTest("Postgres DSN is required for Alembic migration test")
if find_spec("alembic") is None:
self.skipTest("alembic is not installed")
env = os.environ.copy()
env["DATABASE_URL"] = dsn
subprocess.run(
[sys.executable, "-m", "alembic", "upgrade", "head"],
cwd=REPO_ROOT,
env=env,
check=True,
)
from src.domain.schema import CORE_TABLES
from src.infrastructure.repositories import open_backend_repository
repository = open_backend_repository(dsn)
self.assertTrue(set(CORE_TABLES).issubset(repository.schema.list_tables()))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,150 @@
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from pathlib import Path
from fastapi.testclient import TestClient
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
from src.application.seed_data import seed_reference_data # noqa: E402
from src.infrastructure.repositories import open_backend_repository # noqa: E402
from src.presentation.dependencies import get_repository # noqa: E402
from src.presentation.main import app # noqa: E402
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class ResearchManifestPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
self.storage_root = Path(self.tmp_dir.name) / "object-storage"
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(self.storage_root)
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'research.db'}"
self.repository = open_backend_repository(dsn)
self.repository.setup()
seed_reference_data(self.repository)
app.dependency_overrides[get_repository] = lambda: self.repository
self.client = TestClient(app)
def tearDown(self) -> None:
app.dependency_overrides.clear()
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
self.tmp_dir.cleanup()
def test_approved_plan_research_creates_manifest_with_uploaded_artifacts(self) -> None:
article_id = self._create_article_with_approved_plan()
start_response = self.client.post(
f"/api/articles/{article_id}/research/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, start_response.status_code, start_response.text)
body = start_response.json()
self.assertEqual("RESEARCH_RUNNING", body["article"]["status"])
self.assertEqual("RESEARCH", body["job"]["job_type"])
manifest = body["manifest"]
self.assertTrue(manifest["s3_prefix"])
self.assertEqual(article_id, manifest["article_id"])
self.assertEqual(body["job"]["id"], manifest["agent_job_id"])
self.assertGreaterEqual(len(manifest["artifacts"]), 2)
object_keys = [artifact["object_key"] for artifact in manifest["artifacts"]]
content_hashes = [artifact["content_hash"] for artifact in manifest["artifacts"]]
artifact_types = [artifact["artifact_type"] for artifact in manifest["artifacts"]]
source_urls = [artifact["source_url"] for artifact in manifest["artifacts"]]
self.assertEqual(len(object_keys), len(set(object_keys)))
self.assertTrue(all(key.startswith(manifest["s3_prefix"]) for key in object_keys))
self.assertTrue(all(content_hashes))
self.assertIn("source_section_snapshot", artifact_types)
self.assertTrue(all(url.startswith("https://") for url in source_urls))
for artifact in manifest["artifacts"]:
metadata = artifact["metadata"]
self.assertTrue(metadata["source_title"])
self.assertTrue(metadata["domain"])
self.assertTrue(metadata["summary"])
self.assertTrue(metadata["retrieved_at"])
self.assertTrue(metadata["artifact_link"])
self.assertIn("search_path", metadata)
self.assertIn("selection_criteria", metadata)
self.assertIn("head_metadata", metadata)
self.assertIn("server_ip_address", metadata)
self.assertIn("whois_owner", metadata)
self.assertTrue((self.storage_root / artifact["object_key"]).is_file())
list_response = self.client.get(
f"/api/articles/{article_id}/research",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, list_response.status_code, list_response.text)
self.assertEqual([manifest["id"]], [item["id"] for item in list_response.json()["manifests"]])
rerun_response = self.client.post(
f"/api/articles/{article_id}/research/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, rerun_response.status_code, rerun_response.text)
self.assertNotEqual(manifest["id"], rerun_response.json()["manifest"]["id"])
self.assertNotEqual(
manifest["s3_prefix"],
rerun_response.json()["manifest"]["s3_prefix"],
)
def _create_article_with_approved_plan(self) -> str:
site = self.client.get(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()[0]["site"]
article = self.client.post(
"/api/articles",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"target_site_id": site["id"],
"brief_description": "Research auditable AI content operations.",
"working_title": "Auditable AI Content Operations",
"content_type": "longform_guide",
"primary_keyword": "auditable AI content",
},
).json()["article"]
article_id = article["id"]
questions = self.client.post(
f"/api/articles/{article_id}/boundary-questions/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["questions"]
for question in questions:
if question["is_required"]:
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, response.status_code, response.text)
self.client.post(
f"/api/articles/{article_id}/boundary-questions/submit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
plan = self.client.post(
f"/api/articles/{article_id}/plan/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["plan"]
approve_response = self.client.post(
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, approve_response.status_code, approve_response.text)
return article_id
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,170 @@
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
@contextmanager
def repository_dsn() -> Iterator[str]:
configured_dsn = os.environ.get("PIPELINE_TEST_DATABASE_DSN")
if configured_dsn:
yield configured_dsn
return
with tempfile.TemporaryDirectory() as tmp_dir:
yield f"sqlite:///{Path(tmp_dir) / 'pipeline-schema.db'}"
class SchemaStorageContractsIntegrationTest(unittest.TestCase):
def test_schema_contains_task_tables_and_operational_indexes(self) -> None:
from src.domain.schema import CORE_TABLES
from src.infrastructure.repositories import open_backend_repository
with repository_dsn() as dsn:
repository = open_backend_repository(dsn)
repository.setup()
self.assertTrue(set(CORE_TABLES).issubset(repository.schema.list_tables()))
self.assertIn(
"idx_target_sites_slug",
repository.schema.list_indexes("target_sites"),
)
self.assertIn(
"idx_articles_dashboard",
repository.schema.list_indexes("articles"),
)
self.assertIn(
"idx_agent_jobs_queue",
repository.schema.list_indexes("agent_jobs"),
)
self.assertIn(
"idx_workflow_events_timeline",
repository.schema.list_indexes("workflow_events"),
)
def test_required_domain_storage_fields_exist(self) -> None:
from src.infrastructure.repositories import open_backend_repository
with repository_dsn() as dsn:
repository = open_backend_repository(dsn)
repository.setup()
self.assertTrue(
{"status", "publishing_status"}.issubset(
repository.schema.list_columns("articles")
)
)
self.assertIn("status", repository.schema.list_columns("article_drafts"))
self.assertTrue(
{
"created_by",
"created_at",
"diff",
"rollback_target_version_id",
"activated_at",
"publishing_yaml",
"transform_script",
}.issubset(repository.schema.list_columns("script_config_versions"))
)
self.assertTrue(
{
"parent_job_id",
"attempt",
"stdout",
"stderr",
"exit_code",
"duration_ms",
"workspace_path",
"output_files",
}.issubset(repository.schema.list_columns("agent_jobs"))
)
self.assertIn(
"category",
repository.schema.list_columns("boundary_questions"),
)
self.assertTrue(
{
"title_options",
"reader_persona",
"evidence_needs",
"visual_needs",
"seo_notes",
"source_requirements",
"excluded_sources",
"tone",
"audience",
}.issubset(repository.schema.list_columns("article_plans"))
)
self.assertTrue(
{"key_points", "evidence_needs"}.issubset(
repository.schema.list_columns("plan_sections")
)
)
self.assertTrue(
{
"s3_prefix",
"source_urls",
"object_keys",
"content_hashes",
"artifact_types",
"metadata_references",
"artifacts",
}.issubset(repository.schema.list_columns("research_run_manifests"))
)
self.assertTrue(
{
"repository_url",
"branch",
"commit_sha",
"content_bundle_manifest",
"status",
}.issubset(repository.schema.list_columns("publish_commits"))
)
def test_seed_data_is_idempotent_for_users_site_and_script_version(self) -> None:
from src.application.seed_data import seed_reference_data
from src.domain.schema import (
SEEDED_ADMIN_EMAIL,
SEEDED_EDITOR_EMAIL,
SEEDED_TARGET_SITE_SLUG,
)
from src.infrastructure.repositories import open_backend_repository
with repository_dsn() as dsn:
repository = open_backend_repository(dsn)
repository.setup()
seed_reference_data(repository)
seed_reference_data(repository)
site = repository.target_sites.get_by_slug(SEEDED_TARGET_SITE_SLUG)
seeded_sites = [
candidate
for candidate in repository.target_sites.list()
if candidate.slug == SEEDED_TARGET_SITE_SLUG
]
script_versions = repository.script_config_versions.list_for_site(site.id)
self.assertEqual(1, repository.users.count_by_email(SEEDED_ADMIN_EMAIL))
self.assertEqual(1, repository.users.count_by_email(SEEDED_EDITOR_EMAIL))
self.assertEqual([site.id], [candidate.id for candidate in seeded_sites])
self.assertEqual(1, len(script_versions))
self.assertEqual("ACTIVE", script_versions[0]["status"])
self.assertEqual(
str(site.active_script_config_version_id), str(script_versions[0]["id"])
)
self.assertIn("repository:", script_versions[0]["publishing_yaml"])
self.assertIn("transformArticle", script_versions[0]["transform_script"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,134 @@
from __future__ import annotations
import os
import tempfile
import sys
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
import unittest
from uuid import UUID
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
@contextmanager
def repository_dsn() -> Iterator[str]:
configured = os.environ.get("PIPELINE_TEST_DATABASE_DSN")
if configured:
yield configured
return
with tempfile.TemporaryDirectory() as directory:
yield f"sqlite:///{Path(directory) / 'pipeline-audit.db'}"
class ScriptConfigAuditTest(unittest.TestCase):
def test_rollback_writes_audit_event(self) -> None:
with repository_dsn() as dsn:
from src.application.auth import get_current_user_by_email
from src.application.seed_data import seed_reference_data
from src.application.site_config import (
create_script_config_version,
create_target_site,
rollback_script_config_version,
)
from src.domain.contracts import (
PublishingRules,
ScriptConfigVersionCreateRequest,
TargetSiteConfigCreateRequest,
)
from src.infrastructure.repositories import open_backend_repository
repository = open_backend_repository(dsn)
repository.setup()
seed_reference_data(repository)
admin = get_current_user_by_email(repository, "admin@example.com")
site = create_target_site(
repository,
TargetSiteConfigCreateRequest(
name="Audit Test Site",
slug="audit-site",
publishing_type="git_next",
default_language="en",
brand_voice="Audit-focused publishing profile.",
audience="QA operations team.",
seo_rules={"primary_keyword_required": True},
visual_rules={"hero_style": "technical"},
source_rules={"minimum_sources": 1},
publishing_rules=PublishingRules(
repository_url="git@github.com:example/audit-site.git",
production_branch="main",
content_format="mdx",
content_path_template="content/articles/{slug}.mdx",
asset_path_template="public/articles/{slug}/{filename}",
frontmatter_mapping={"title": "title"},
dry_run_renderer="next-mdx",
),
active_script_config_version_id=None,
),
).site
initial = create_script_config_version(
repository,
site_id=site.id,
current_user=admin,
request=self._script_version_payload(summary="initial", activate=True),
).version
candidate = create_script_config_version(
repository,
site_id=site.id,
current_user=admin,
request=self._script_version_payload(
summary="candidate",
activate=True,
rollback_target_version_id=initial.id,
),
).version
rolled_back = rollback_script_config_version(
repository,
site_id=site.id,
version_id=initial.id,
current_user=admin,
)
self.assertEqual(str(initial.id), str(rolled_back.version.id))
rollback_events = [
event
for event in repository.script_config_version_events.list_for_site(site.id)
if event["event_type"] == "SCRIPT_CONFIG_VERSION_ROLLBACK"
]
self.assertEqual(1, len(rollback_events))
rollback_event = rollback_events[0]
self.assertEqual(str(site.id), str(rollback_event["target_site_id"]))
self.assertEqual(str(initial.id), str(rollback_event["version_id"]))
self.assertEqual(str(admin.id), str(rollback_event["actor_user_id"]))
self.assertEqual(
str(candidate.id),
rollback_event["payload"]["rollback_target_version_id"],
)
active_site = repository.target_sites.get_by_id(site.id)
self.assertEqual(initial.id, active_site.active_script_config_version_id)
def _script_version_payload(
self,
summary: str,
activate: bool = False,
rollback_target_version_id: UUID | None = None,
) -> ScriptConfigVersionCreateRequest:
payload = ScriptConfigVersionCreateRequest(
publishing_yaml="target:\n repo: audit\n",
transform_script="export function transformArticle(article) { return article; }",
diff={"summary": summary},
activate=activate,
)
if rollback_target_version_id is not None:
payload.rollback_target_version_id = rollback_target_version_id
return payload
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,74 @@
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
SEEDED_SITE_SLUG = "b2b_saas_blog"
@contextmanager
def repository_dsn() -> Iterator[str]:
configured_dsn = os.environ.get("PIPELINE_TEST_DATABASE_DSN")
if configured_dsn:
yield configured_dsn
return
with tempfile.TemporaryDirectory() as tmp_dir:
yield f"sqlite:///{Path(tmp_dir) / 'pipeline-seed-red.db'}"
class SeededTargetSiteLookupIntegrationTest(unittest.TestCase):
def test_seeded_git_next_target_site_lookup_by_slug_is_idempotent(self) -> None:
from src.application.seed_data import seed_reference_data
from src.infrastructure.repositories import open_backend_repository
with repository_dsn() as dsn:
repository = open_backend_repository(dsn)
repository.setup()
seed_reference_data(repository)
seed_reference_data(repository)
site = repository.target_sites.get_by_slug(SEEDED_SITE_SLUG)
seeded_sites = [
candidate
for candidate in repository.target_sites.list()
if candidate.slug == SEEDED_SITE_SLUG
]
from src.domain.contracts import TargetSiteConfig
self.assertIsInstance(site, TargetSiteConfig)
self.assertEqual([site.id], [candidate.id for candidate in seeded_sites])
self.assertEqual("B2B SaaS Blog", site.name)
self.assertEqual("git_next", site.publishing_type)
self.assertEqual("en", site.default_language)
self.assertEqual(
"git@github.com:example/site.git",
site.publishing_rules.repository_url,
)
self.assertEqual("main", site.publishing_rules.production_branch)
self.assertEqual("mdx", site.publishing_rules.content_format)
self.assertEqual(
"content/articles/{slug}.mdx",
site.publishing_rules.content_path_template,
)
self.assertEqual(
"public/articles/{slug}/{filename}",
site.publishing_rules.asset_path_template,
)
self.assertIsNotNone(site.active_script_config_version_id)
if __name__ == "__main__":
unittest.main()
+12 -5
View File
@@ -1,12 +1,19 @@
FROM node:22-alpine FROM node:22-slim
WORKDIR /app WORKDIR /app
COPY apps/frontend/package.json ./package.json RUN corepack enable
RUN npm install
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 EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--hostname", "0.0.0.0"] CMD ["pnpm", "exec", "next", "dev", "--hostname", "0.0.0.0"]
+9 -1
View File
@@ -4,9 +4,13 @@
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start" "start": "next start",
"test:roles": "node tests/role-navigation.test.mjs",
"test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs && node tests/agent_jobs.model.test.mjs && node tests/boundary_questions.model.test.mjs && node tests/plan_review.model.test.mjs && node tests/research_manifest.model.test.mjs",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@pipeline/shared": "workspace:*",
"next": "16.2.6", "next": "16.2.6",
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0" "react-dom": "19.0.0"
@@ -17,6 +21,10 @@
"@types/react-dom": "19.0.2", "@types/react-dom": "19.0.2",
"typescript": "5.7.2" "typescript": "5.7.2"
}, },
"optionalDependencies": {
"@next/swc-linux-arm64-gnu": "16.2.6",
"@next/swc-linux-x64-gnu": "16.2.6"
},
"overrides": { "overrides": {
"postcss": "8.5.14" "postcss": "8.5.14"
} }
@@ -0,0 +1,3 @@
import AdminJobsPage from "@/pages/admin-jobs";
export default AdminJobsPage;
@@ -0,0 +1,3 @@
import AdminScriptsPage from "@/pages/admin-scripts";
export { AdminScriptsPage as default };
@@ -0,0 +1,3 @@
import AdminSitesPage from "@/pages/admin-sites";
export default AdminSitesPage;
@@ -0,0 +1,14 @@
import ArticleBoundaryQuestionsPage from "@/pages/article-boundary-questions";
type DynamicArticleBoundaryQuestionsPageProps = {
params: {
articleId: string;
};
};
export default async function DynamicArticleBoundaryQuestionsPage({
params,
}: DynamicArticleBoundaryQuestionsPageProps) {
const { articleId } = params;
return <ArticleBoundaryQuestionsPage articleId={articleId} />;
}
@@ -0,0 +1,14 @@
import ArticleDetailPage from "@/pages/article-detail";
type DynamicArticleDetailPageProps = {
params: {
articleId: string;
};
};
export default async function DynamicArticleDetailPage({
params,
}: DynamicArticleDetailPageProps) {
const { articleId } = params;
return <ArticleDetailPage articleId={articleId} />;
}
@@ -0,0 +1,14 @@
import ArticlePlansPage from "@/pages/article-plans";
type DynamicArticlePlansPageProps = {
params: {
articleId: string;
};
};
export default async function DynamicArticlePlansPage({
params,
}: DynamicArticlePlansPageProps) {
const { articleId } = params;
return <ArticlePlansPage articleId={articleId} />;
}
@@ -0,0 +1,14 @@
import ArticleResearchPage from "@/pages/article-research";
type DynamicArticleResearchPageProps = {
params: {
articleId: string;
};
};
export default async function DynamicArticleResearchPage({
params,
}: DynamicArticleResearchPageProps) {
const { articleId } = params;
return <ArticleResearchPage articleId={articleId} />;
}
@@ -0,0 +1,3 @@
import NewArticlePage from "@/pages/article-new";
export { NewArticlePage as default };
+335
View File
@@ -19,3 +19,338 @@ main {
min-height: 100vh; min-height: 100vh;
padding: 48px; padding: 48px;
} }
.pageHeader {
display: flex;
max-width: 920px;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-bottom: 28px;
}
.pageHeader h1 {
margin: 0 0 8px;
font-size: 32px;
line-height: 1.2;
letter-spacing: 0;
}
.pageHeader p {
margin: 0;
color: #667085;
}
.roleBadge {
display: inline-flex;
min-height: 32px;
align-items: center;
border: 1px solid #b7c0cc;
border-radius: 6px;
padding: 0 12px;
background: #ffffff;
color: #344054;
font-size: 14px;
}
.roleNavigation {
display: grid;
max-width: 920px;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
padding: 0;
margin: 0;
list-style: none;
}
.roleNavigation a {
display: flex;
min-height: 52px;
align-items: center;
border: 1px solid #d0d5dd;
border-radius: 8px;
padding: 0 16px;
background: #ffffff;
color: #1d2733;
font-weight: 600;
text-decoration: none;
}
.panel {
max-width: 980px;
}
.table {
width: 100%;
border-collapse: collapse;
margin-top: 16px;
background: #ffffff;
border: 1px solid #d0d5dd;
border-radius: 8px;
overflow: hidden;
}
.table th,
.table td {
border-bottom: 1px solid #d0d5dd;
padding: 10px;
text-align: left;
vertical-align: top;
}
.table th {
background: #eef2f6;
}
.table a {
font-weight: 600;
color: #1f4b99;
text-decoration: none;
}
.table a:hover {
text-decoration: underline;
}
.emptyState {
margin-top: 8px;
padding: 16px;
border: 1px dashed #b7c0cc;
border-radius: 8px;
background: #ffffff;
}
.articleForm {
max-width: 620px;
display: grid;
gap: 8px;
padding: 16px;
border: 1px solid #d0d5dd;
border-radius: 8px;
background: #ffffff;
}
.articleForm label {
font-size: 13px;
font-weight: 600;
}
.articleForm input,
.articleForm select,
.articleForm textarea {
width: 100%;
border-radius: 6px;
border: 1px solid #d0d5dd;
padding: 10px;
font: inherit;
}
.articleForm textarea {
min-height: 120px;
}
.codeArea {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
min-height: 120px;
white-space: pre;
}
.fieldError {
margin: 0;
color: #b42318;
font-size: 12px;
}
.formSuccess {
margin: 0;
color: #027a48;
font-weight: 600;
}
.formError {
margin: 4px 0 0;
color: #b42318;
font-weight: 600;
}
.inlineLinks {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.inlineLinks a {
text-decoration: none;
border: 1px solid #d0d5dd;
border-radius: 6px;
padding: 8px 12px;
background: #ffffff;
display: inline-flex;
}
.inlineLinks .activeLink {
border-color: #1f4b99;
color: #1f4b99;
font-weight: 600;
}
.table button {
border-radius: 6px;
border: 1px solid #1d2733;
background: #ffffff;
color: #1d2733;
min-height: 34px;
padding: 0 10px;
margin-right: 6px;
}
.tableAction {
background: #eceff5 !important;
}
.formActions {
margin-top: 8px;
}
.formActions button {
border-radius: 6px;
border: 1px solid #1d2733;
background: #1d2733;
color: #ffffff;
min-height: 40px;
padding: 0 14px;
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.sectionHeader h2,
.sectionHeader p {
margin: 0;
}
.sectionHeader button {
border-radius: 6px;
border: 1px solid #1d2733;
background: #ffffff;
color: #1d2733;
min-height: 40px;
padding: 0 14px;
}
.boundaryQuestions {
display: grid;
gap: 16px;
}
.questionList {
display: grid;
gap: 12px;
}
.questionItem {
border: 1px solid #d0d5dd;
border-radius: 8px;
padding: 14px;
background: #ffffff;
}
.questionHeader {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: start;
}
.questionHeader p {
margin: 6px 0 10px;
color: #475467;
}
.questionItem textarea {
width: 100%;
min-height: 96px;
border: 1px solid #d0d5dd;
border-radius: 6px;
padding: 10px;
font: inherit;
}
.requiredBadge,
.optionalBadge {
display: inline-flex;
min-height: 28px;
align-items: center;
border-radius: 6px;
padding: 0 10px;
font-size: 12px;
font-weight: 700;
}
.requiredBadge {
border: 1px solid #b42318;
color: #b42318;
background: #fff4f2;
}
.optionalBadge {
border: 1px solid #667085;
color: #344054;
background: #f9fafb;
}
.detailGrid {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 10px;
}
.detailGrid div {
border: 1px solid #d0d5dd;
border-radius: 8px;
padding: 10px;
background: #ffffff;
}
.detailGrid dt {
font-size: 12px;
text-transform: uppercase;
color: #667085;
margin-bottom: 6px;
}
.detailGrid dd {
margin: 0;
font-weight: 600;
}
.timeline {
margin: 0;
list-style: none;
padding: 0;
max-width: 980px;
}
.timeline li {
border-bottom: 1px solid #d0d5dd;
padding: 10px 0;
}
@media (max-width: 640px) {
main {
padding: 28px;
}
.pageHeader {
align-items: flex-start;
flex-direction: column;
}
}
+3 -8
View File
@@ -1,8 +1,3 @@
export default function HomePage() { import DashboardPage from "@/pages/dashboard";
return (
<main> export { DashboardPage as default };
<h1>AI Content Pipeline</h1>
<p>Dockerized monorepo foundation is running.</p>
</main>
);
}
@@ -0,0 +1,97 @@
import type {
ScriptConfigVersionCreateRequest,
ScriptConfigVersionSummary,
} from "@pipeline/shared";
export type AdminScriptVersionRow = {
id: string;
version: number;
status: string;
createdBy: string;
createdAt: string;
activatedAt: string | null;
publishingYamlHash: string;
transformScriptHash: string;
isActive: boolean;
rollbackTargetVersionId: string | null;
diff: string;
};
export type NewAdminScriptVersionDraft = {
publishingYaml: string;
transformScript: string;
diff: string;
activate: boolean;
rollbackTargetVersionId: string;
};
export type DraftValidationErrors = Partial<Record<keyof NewAdminScriptVersionDraft, string>>;
const DEFAULT_DIFF = '{\n "summary": "manual publish pipeline change"\n}';
const DEFAULT_PUBLISHING_YAML = "target: site\nrepository:\n branch: main\n";
const DEFAULT_TRANSFORM_SCRIPT = "export function transformArticle(article) {\n return article;\n}\n";
export function buildAdminScriptVersionRows(
versions: readonly ScriptConfigVersionSummary[],
): AdminScriptVersionRow[] {
return versions.map((version) => ({
id: version.id,
version: version.version,
status: version.status,
createdBy: version.created_by,
createdAt: version.created_at,
activatedAt: version.activated_at ?? null,
publishingYamlHash: version.publishing_yaml_hash,
transformScriptHash: version.transform_script_hash,
isActive: version.status === "ACTIVE",
rollbackTargetVersionId: version.rollback_target_version_id ?? null,
diff: JSON.stringify(version.diff),
}));
}
export function defaultScriptVersionDraft(): NewAdminScriptVersionDraft {
return {
publishingYaml: DEFAULT_PUBLISHING_YAML,
transformScript: DEFAULT_TRANSFORM_SCRIPT,
diff: DEFAULT_DIFF,
activate: false,
rollbackTargetVersionId: "",
};
}
export function buildCreateScriptVersionPayload(
draft: NewAdminScriptVersionDraft,
): ScriptConfigVersionCreateRequest {
const parsedDiff = parseJsonObject(draft.diff) ?? {};
return {
publishing_yaml: draft.publishingYaml,
transform_script: draft.transformScript,
diff: parsedDiff,
activate: draft.activate,
rollback_target_version_id: draft.rollbackTargetVersionId
? (draft.rollbackTargetVersionId as string)
: null,
version: null,
};
}
export function parseJsonObject(value: string): Record<string, unknown> | null {
if (!value.trim()) {
return {};
}
try {
const parsed = JSON.parse(value);
if (
parsed &&
typeof parsed === "object" &&
!Array.isArray(parsed) &&
parsed !== null
) {
return parsed as Record<string, unknown>;
}
} catch {
return null;
}
return null;
}
@@ -0,0 +1,300 @@
"use client";
import { useMemo, useState, type FormEvent } from "react";
import type { ScriptConfigVersionSummary, TargetSiteConfig } from "@pipeline/shared";
import { ApiError, DEMO_ADMIN_EMAIL, activateScriptConfigVersion, createScriptConfigVersion, fetchScriptConfigVersions, rollbackScriptConfigVersion } from "@/shared/pipeline-api";
import {
buildAdminScriptVersionRows,
buildCreateScriptVersionPayload,
defaultScriptVersionDraft,
parseJsonObject,
type DraftValidationErrors,
type NewAdminScriptVersionDraft,
} from "./model";
type AdminScriptVersionsPanelProps = {
site: TargetSiteConfig;
initialVersions: readonly ScriptConfigVersionSummary[];
};
function parseOptionalError(value: string): string | null {
if (value.trim().length === 0) {
return "Required value";
}
return null;
}
export function AdminScriptVersionsPanel({
site,
initialVersions,
}: AdminScriptVersionsPanelProps) {
const [versions, setVersions] = useState<readonly ScriptConfigVersionSummary[]>(
initialVersions,
);
const [draft, setDraft] = useState<NewAdminScriptVersionDraft>(
defaultScriptVersionDraft(),
);
const [errors, setErrors] = useState<DraftValidationErrors>({});
const [formError, setFormError] = useState("");
const [actionMessage, setActionMessage] = useState("");
const [isSubmitting, setSubmitting] = useState(false);
const rows = useMemo(
() =>
buildAdminScriptVersionRows(versions),
[versions],
);
const hasVersions = rows.length > 0;
const canSubmit = !isSubmitting;
const rollbackCandidates = rows.filter((row) => row.id !== site.active_script_config_version_id);
async function refreshVersions() {
const latest = await fetchScriptConfigVersions(
site.id,
DEMO_ADMIN_EMAIL,
);
setVersions(latest.versions);
}
function updateField<K extends keyof NewAdminScriptVersionDraft>(
field: K,
value: NewAdminScriptVersionDraft[K],
) {
setDraft((current) => ({ ...current, [field]: value }));
}
function clearMessages() {
setFormError("");
setActionMessage("");
}
async function handleCreateVersion(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const nextErrors: DraftValidationErrors = {};
const publishingYamlError = parseOptionalError(draft.publishingYaml);
const transformScriptError = parseOptionalError(draft.transformScript);
const diffObject = parseJsonObject(draft.diff);
if (publishingYamlError) {
nextErrors.publishingYaml = publishingYamlError;
}
if (transformScriptError) {
nextErrors.transformScript = transformScriptError;
}
if (diffObject === null) {
nextErrors.diff = "Invalid JSON object";
}
if (draft.rollbackTargetVersionId) {
const validRollback = rows.some((row) => row.id === draft.rollbackTargetVersionId);
if (!validRollback) {
nextErrors.rollbackTargetVersionId = "Select a valid version ID";
}
}
if (Object.keys(nextErrors).length > 0) {
setErrors(nextErrors);
return;
}
setErrors({});
clearMessages();
setSubmitting(true);
try {
const payload = buildCreateScriptVersionPayload(draft);
await createScriptConfigVersion(site.id, payload, DEMO_ADMIN_EMAIL);
setActionMessage("New version created");
await refreshVersions();
} catch (error) {
if (error instanceof ApiError) {
setFormError(error.message || "Request failed");
} else {
setFormError("Unable to create publishing version");
}
} finally {
setSubmitting(false);
}
}
async function handleActivate(versionId: string) {
setSubmitting(true);
clearMessages();
try {
await activateScriptConfigVersion(site.id, versionId, DEMO_ADMIN_EMAIL);
await refreshVersions();
setActionMessage("Version activated");
} catch (error) {
if (error instanceof ApiError) {
setFormError(error.message || "Request failed");
} else {
setFormError("Unable to activate version");
}
} finally {
setSubmitting(false);
}
}
async function handleRollback(versionId: string) {
setSubmitting(true);
clearMessages();
try {
await rollbackScriptConfigVersion(site.id, versionId, DEMO_ADMIN_EMAIL);
await refreshVersions();
setActionMessage("Rollback applied");
} catch (error) {
if (error instanceof ApiError) {
setFormError(error.message || "Request failed");
} else {
setFormError("Unable to rollback version");
}
} finally {
setSubmitting(false);
}
}
return (
<section>
<h2>{site.name} script versions</h2>
{formError ? <p className="formError">Error: {formError}</p> : null}
{actionMessage ? <p className="formSuccess">{actionMessage}</p> : null}
<h3>Create new version</h3>
<form className="articleForm" onSubmit={handleCreateVersion}>
<label htmlFor="publishingYaml">Publishing YAML</label>
<textarea
id="publishingYaml"
className="codeArea"
value={draft.publishingYaml}
onChange={(event) => updateField("publishingYaml", event.target.value)}
/>
{errors.publishingYaml ? <p className="fieldError">{errors.publishingYaml}</p> : null}
<label htmlFor="transformScript">Transform script</label>
<textarea
id="transformScript"
className="codeArea"
value={draft.transformScript}
onChange={(event) => updateField("transformScript", event.target.value)}
/>
{errors.transformScript ? (
<p className="fieldError">{errors.transformScript}</p>
) : null}
<label htmlFor="diff">Diff payload (JSON)</label>
<textarea
id="diff"
className="codeArea"
value={draft.diff}
onChange={(event) => updateField("diff", event.target.value)}
/>
{errors.diff ? <p className="fieldError">{errors.diff}</p> : null}
<label htmlFor="activate">
<input
id="activate"
type="checkbox"
checked={draft.activate}
onChange={(event) => updateField("activate", event.target.checked)}
/>
Activate on create
</label>
<label htmlFor="rollbackTargetVersionId">Rollback target version (optional)</label>
<select
id="rollbackTargetVersionId"
value={draft.rollbackTargetVersionId}
onChange={(event) => updateField("rollbackTargetVersionId", event.target.value)}
>
<option value="">None</option>
{rollbackCandidates.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
v{candidate.version}
</option>
))}
</select>
{errors.rollbackTargetVersionId ? (
<p className="fieldError">{errors.rollbackTargetVersionId}</p>
) : null}
<div className="formActions">
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? "Saving..." : "Create version"}
</button>
</div>
</form>
<h3>Version history</h3>
{!hasVersions ? (
<p className="emptyState">No versions yet.</p>
) : (
<table className="table">
<thead>
<tr>
<th>Version</th>
<th>Status</th>
<th>Created</th>
<th>Activated</th>
<th>Author</th>
<th>Hashes</th>
<th>Rollback target</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{rows.map((version) => (
<tr key={version.id}>
<td>
{version.version} {version.isActive ? "(active)" : null}
<div style={{ marginTop: 6 }}>
<strong>diff</strong>: {version.diff}
</div>
</td>
<td>{version.status}</td>
<td>{new Date(version.createdAt).toLocaleString()}</td>
<td>
{version.activatedAt
? new Date(version.activatedAt).toLocaleString()
: "—"}
</td>
<td>{version.createdBy}</td>
<td>
<div>YAML: {version.publishingYamlHash.slice(0, 8)}</div>
<div>Script: {version.transformScriptHash.slice(0, 8)}</div>
</td>
<td>{version.rollbackTargetVersionId ?? "—"}</td>
<td>
{!version.isActive ? (
<>
<button
type="button"
onClick={() => handleActivate(version.id)}
disabled={isSubmitting}
>
Activate
</button>
<button
type="button"
onClick={() => handleRollback(version.id)}
disabled={isSubmitting}
className="tableAction"
>
Rollback
</button>
</>
) : (
<span>Current</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}
@@ -0,0 +1,325 @@
"use client";
import { useMemo, useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import type { TargetSiteConfigCreateRequest } from "@pipeline/shared";
import { ApiError, createTargetSite, DEMO_ADMIN_EMAIL } from "@/shared/pipeline-api";
type AdminSiteDraft = {
name: string;
slug: string;
defaultLanguage: string;
brandVoice: string;
audience: string;
publishingType: string;
repositoryUrl: string;
productionBranch: string;
contentFormat: string;
contentPathTemplate: string;
assetPathTemplate: string;
dryRunRenderer: string;
frontmatterMapping: string;
seoRules: string;
visualRules: string;
sourceRules: string;
};
type ValidationErrors = Partial<Record<keyof AdminSiteDraft, string>>;
const defaultDraft = (): AdminSiteDraft => ({
name: "",
slug: "",
defaultLanguage: "en",
brandVoice: "Practical and helpful editorial guidance.",
audience: "Content and marketing teams.",
publishingType: "git_next",
repositoryUrl: "",
productionBranch: "main",
contentFormat: "mdx",
contentPathTemplate: "content/articles/{slug}.mdx",
assetPathTemplate: "public/articles/{slug}/{filename}",
dryRunRenderer: "next-mdx",
frontmatterMapping: JSON.stringify(
{
title: "title",
description: "description",
publishedAt: "published_at",
},
null,
2,
),
seoRules: JSON.stringify({}, null, 2),
visualRules: JSON.stringify({}, null, 2),
sourceRules: JSON.stringify({}, null, 2),
});
function parseJsonField(value: string): Record<string, unknown> | null {
if (!value.trim()) {
return {};
}
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
return null;
}
return null;
}
export function AdminSiteCreateForm() {
const router = useRouter();
const [values, setValues] = useState<AdminSiteDraft>(defaultDraft());
const [errors, setErrors] = useState<ValidationErrors>({});
const [formError, setFormError] = useState("");
const [isSubmitting, setSubmitting] = useState(false);
const canSubmit = useMemo(
() =>
!isSubmitting &&
values.name.trim() !== "" &&
values.slug.trim() !== "" &&
values.repositoryUrl.trim() !== "" &&
values.brandVoice.trim() !== "" &&
values.audience.trim() !== "",
[isSubmitting, values],
);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const nextErrors: ValidationErrors = {};
const parsedFrontmatter = parseJsonField(values.frontmatterMapping);
const parsedSeo = parseJsonField(values.seoRules);
const parsedVisual = parseJsonField(values.visualRules);
const parsedSource = parseJsonField(values.sourceRules);
if (parsedFrontmatter === null) {
nextErrors.frontmatterMapping = "Invalid JSON object";
}
if (parsedSeo === null) {
nextErrors.seoRules = "Invalid JSON object";
}
if (parsedVisual === null) {
nextErrors.visualRules = "Invalid JSON object";
}
if (parsedSource === null) {
nextErrors.sourceRules = "Invalid JSON object";
}
if (!values.slug.trim()) {
nextErrors.slug = "Slug is required";
}
if (!values.name.trim()) {
nextErrors.name = "Name is required";
}
if (!values.repositoryUrl.trim()) {
nextErrors.repositoryUrl = "Repository URL is required";
}
if (!values.brandVoice.trim()) {
nextErrors.brandVoice = "Brand voice is required";
}
if (!values.audience.trim()) {
nextErrors.audience = "Audience is required";
}
if (Object.keys(nextErrors).length > 0) {
setErrors(nextErrors);
return;
}
setErrors({});
setFormError("");
setSubmitting(true);
try {
const payload: TargetSiteConfigCreateRequest = {
name: values.name,
slug: values.slug,
publishing_type: values.publishingType,
default_language: values.defaultLanguage,
brand_voice: values.brandVoice,
audience: values.audience,
seo_rules: parsedSeo ?? {},
visual_rules: parsedVisual ?? {},
source_rules: parsedSource ?? {},
publishing_rules: {
repository_url: values.repositoryUrl,
production_branch: values.productionBranch,
content_format: values.contentFormat,
content_path_template: values.contentPathTemplate,
asset_path_template: values.assetPathTemplate,
frontmatter_mapping: parsedFrontmatter ?? {},
dry_run_renderer: values.dryRunRenderer || null,
},
};
await createTargetSite(payload, DEMO_ADMIN_EMAIL);
router.push("/admin/sites");
router.refresh();
} catch (error) {
if (error instanceof ApiError) {
setFormError(error.message || "Request failed");
} else {
setFormError("Unable to create site config");
}
} finally {
setSubmitting(false);
}
}
function updateField<K extends keyof AdminSiteDraft>(
field: K,
value: AdminSiteDraft[K],
) {
setValues((current) => ({ ...current, [field]: value }));
}
return (
<form className="articleForm" onSubmit={handleSubmit}>
<h2>Create target site config</h2>
{formError ? <p className="formError">Error: {formError}</p> : null}
<label htmlFor="name">Name</label>
<input
id="name"
value={values.name}
onChange={(event) => updateField("name", event.target.value)}
placeholder="Editorial blog"
/>
{errors.name ? <p className="fieldError">{errors.name}</p> : null}
<label htmlFor="slug">Slug</label>
<input
id="slug"
value={values.slug}
onChange={(event) => updateField("slug", event.target.value)}
placeholder="editorial-blog"
/>
{errors.slug ? <p className="fieldError">{errors.slug}</p> : null}
<label htmlFor="defaultLanguage">Default language</label>
<input
id="defaultLanguage"
value={values.defaultLanguage}
onChange={(event) => updateField("defaultLanguage", event.target.value)}
/>
<label htmlFor="publishingType">Publishing type</label>
<input
id="publishingType"
value={values.publishingType}
onChange={(event) => updateField("publishingType", event.target.value)}
/>
<label htmlFor="brandVoice">Brand voice</label>
<input
id="brandVoice"
value={values.brandVoice}
onChange={(event) => updateField("brandVoice", event.target.value)}
placeholder="Confident and practical"
/>
{errors.brandVoice ? <p className="fieldError">{errors.brandVoice}</p> : null}
<label htmlFor="audience">Audience</label>
<input
id="audience"
value={values.audience}
onChange={(event) => updateField("audience", event.target.value)}
placeholder="Marketing and editorial teams"
/>
{errors.audience ? <p className="fieldError">{errors.audience}</p> : null}
<label htmlFor="repositoryUrl">Repository URL</label>
<input
id="repositoryUrl"
value={values.repositoryUrl}
onChange={(event) => updateField("repositoryUrl", event.target.value)}
placeholder="git@github.com:org/repo.git"
/>
{errors.repositoryUrl ? <p className="fieldError">{errors.repositoryUrl}</p> : null}
<label htmlFor="productionBranch">Production branch</label>
<input
id="productionBranch"
value={values.productionBranch}
onChange={(event) => updateField("productionBranch", event.target.value)}
placeholder="main"
/>
<label htmlFor="contentFormat">Content format</label>
<input
id="contentFormat"
value={values.contentFormat}
onChange={(event) => updateField("contentFormat", event.target.value)}
placeholder="mdx"
/>
<label htmlFor="contentPathTemplate">Content path template</label>
<input
id="contentPathTemplate"
value={values.contentPathTemplate}
onChange={(event) => updateField("contentPathTemplate", event.target.value)}
placeholder="content/articles/{slug}.mdx"
/>
<label htmlFor="assetPathTemplate">Asset path template</label>
<input
id="assetPathTemplate"
value={values.assetPathTemplate}
onChange={(event) => updateField("assetPathTemplate", event.target.value)}
placeholder="public/articles/{slug}/{filename}"
/>
<label htmlFor="dryRunRenderer">Dry-run renderer</label>
<input
id="dryRunRenderer"
value={values.dryRunRenderer}
onChange={(event) => updateField("dryRunRenderer", event.target.value)}
/>
<label htmlFor="frontmatterMapping">Frontmatter mapping (JSON)</label>
<textarea
id="frontmatterMapping"
className="codeArea"
value={values.frontmatterMapping}
onChange={(event) => updateField("frontmatterMapping", event.target.value)}
/>
{errors.frontmatterMapping ? (
<p className="fieldError">{errors.frontmatterMapping}</p>
) : null}
<label htmlFor="seoRules">SEO rules (JSON)</label>
<textarea
id="seoRules"
className="codeArea"
value={values.seoRules}
onChange={(event) => updateField("seoRules", event.target.value)}
/>
{errors.seoRules ? <p className="fieldError">{errors.seoRules}</p> : null}
<label htmlFor="visualRules">Visual rules (JSON)</label>
<textarea
id="visualRules"
className="codeArea"
value={values.visualRules}
onChange={(event) => updateField("visualRules", event.target.value)}
/>
{errors.visualRules ? <p className="fieldError">{errors.visualRules}</p> : null}
<label htmlFor="sourceRules">Source rules (JSON)</label>
<textarea
id="sourceRules"
className="codeArea"
value={values.sourceRules}
onChange={(event) => updateField("sourceRules", event.target.value)}
/>
{errors.sourceRules ? <p className="fieldError">{errors.sourceRules}</p> : null}
<div className="formActions">
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? "Creating..." : "Create site"}
</button>
</div>
</form>
);
}
@@ -0,0 +1,27 @@
import type { TargetSiteConfig } from "@pipeline/shared";
export type AdminSiteRow = {
id: string;
name: string;
slug: string;
publishingRepository: string;
productionBranch: string;
activeVersionId: string | null;
createdAt: string;
updatedAt: string;
};
export function buildAdminSiteRows(
sites: readonly TargetSiteConfig[],
): AdminSiteRow[] {
return sites.map((site) => ({
id: site.id,
name: site.name,
slug: site.slug,
publishingRepository: site.publishing_rules.repository_url,
productionBranch: site.publishing_rules.production_branch,
activeVersionId: site.active_script_config_version_id ?? null,
createdAt: site.created_at,
updatedAt: site.updated_at,
}));
}
@@ -0,0 +1,44 @@
import Link from "next/link";
import type { AdminSiteRow } from "./model";
type AdminSiteConfigTableProps = {
rows: readonly AdminSiteRow[];
};
export function AdminSiteConfigTable({ rows }: AdminSiteConfigTableProps) {
if (rows.length === 0) {
return <p className="emptyState">No target sites yet.</p>;
}
return (
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Repository</th>
<th>Branch</th>
<th>Active config version</th>
<th>Revisions</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>{row.name}</td>
<td>{row.slug}</td>
<td>{row.publishingRepository}</td>
<td>{row.productionBranch}</td>
<td>{row.activeVersionId ?? "none"}</td>
<td>
<Link href={`/admin/scripts?siteId=${row.id}`}>Versions</Link>
</td>
<td>{new Date(row.updatedAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
);
}
@@ -0,0 +1,33 @@
import type { AgentJobSummary } from "@pipeline/shared";
export type AgentJobRow = {
id: string;
type: string;
status: string;
attempt: number;
parentJobId: string | null;
workspacePath: string | null;
stdout: string;
stderr: string;
exitCode: number | null;
durationMs: number | null;
outputFiles: string;
error: string;
};
export function buildAgentJobRows(jobs: readonly AgentJobSummary[]): AgentJobRow[] {
return jobs.map((job) => ({
id: job.id,
type: job.job_type,
status: job.status,
attempt: job.attempt ?? 1,
parentJobId: job.parent_job_id ?? null,
workspacePath: job.workspace_path ?? null,
stdout: job.stdout ?? "",
stderr: job.stderr ?? "",
exitCode: job.exit_code ?? null,
durationMs: job.duration_ms ?? null,
outputFiles: (job.output_files ?? []).map((file) => file.path).join(", "),
error: job.error_category ?? job.error_message ?? "",
}));
}
@@ -0,0 +1,51 @@
import type { AgentJobRow } from "./model";
type AgentJobsTableProps = {
rows: readonly AgentJobRow[];
};
export function AgentJobsTable({ rows }: AgentJobsTableProps) {
if (rows.length === 0) {
return <p className="emptyState">No agent jobs yet.</p>;
}
return (
<table className="table">
<thead>
<tr>
<th>Job</th>
<th>Status</th>
<th>Attempt</th>
<th>Runtime</th>
<th>Workspace</th>
<th>Logs</th>
<th>Outputs</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>
<div>{row.type}</div>
<small>{row.id}</small>
{row.parentJobId ? <div>retry of {row.parentJobId}</div> : null}
</td>
<td>{row.status}</td>
<td>{row.attempt}</td>
<td>
<div>exit: {row.exitCode ?? "pending"}</div>
<div>{row.durationMs ?? 0} ms</div>
{row.error ? <div>{row.error}</div> : null}
</td>
<td>{row.workspacePath ?? "pending"}</td>
<td>
<pre className="logBlock">{row.stdout || "stdout pending"}</pre>
{row.stderr ? <pre className="logBlock">{row.stderr}</pre> : null}
</td>
<td>{row.outputFiles || "none"}</td>
</tr>
))}
</tbody>
</table>
);
}
@@ -0,0 +1,62 @@
import type { ArticleSummary, TargetSiteConfig } from "@pipeline/shared";
export type DashboardRow = {
id: string;
targetSiteName: string;
workingTitle: string | null;
briefDescription: string;
status: string;
publishingStatus: string;
assignedEditor: string;
updatedAt: string;
nextAction: string;
};
const EMPTY_ASSIGNED_EDITOR_LABEL = "Unassigned";
export function getNextActionForStatus(status: string): string {
if (status === "ARTICLE_BRIEF_CREATED") {
return "Run boundary questions";
}
if (status === "BOUNDARY_QUESTIONS_GENERATED") {
return "Submit boundary answers";
}
if (status === "BOUNDARY_ANSWERS_SUBMITTED") {
return "Create plan";
}
if (status === "PLAN_REVIEW_REQUIRED") {
return "Gather evidence";
}
return "Continue workflow";
}
function getAssignedEditorLabel(article: ArticleSummary): string {
return article.assigned_editor_id ?? EMPTY_ASSIGNED_EDITOR_LABEL;
}
function getTargetSiteName(
article: ArticleSummary,
siteById: Map<string, TargetSiteConfig>,
): string {
const site = siteById.get(article.target_site_id);
return site?.name ?? "Unknown site";
}
export function buildDashboardRows(
articles: readonly ArticleSummary[],
sites: readonly TargetSiteConfig[],
): DashboardRow[] {
const siteById = new Map(sites.map((site) => [site.id, site]));
return articles.map((article) => ({
id: article.id,
targetSiteName: getTargetSiteName(article, siteById),
workingTitle: article.working_title ?? null,
briefDescription: article.brief_description,
status: article.status,
publishingStatus: article.publishing_status,
assignedEditor: getAssignedEditorLabel(article),
updatedAt: article.updated_at,
nextAction: getNextActionForStatus(article.status),
}));
}
@@ -0,0 +1,44 @@
import Link from "next/link";
import type { DashboardRow } from "./model";
type DashboardTableProps = {
rows: readonly DashboardRow[];
};
export function DashboardTable({ rows }: DashboardTableProps) {
if (rows.length === 0) {
return <p className="emptyState">No article workflows yet.</p>;
}
return (
<table className="table">
<thead>
<tr>
<th>Article title</th>
<th>Target website</th>
<th>Status</th>
<th>Assigned editor</th>
<th>Last updated</th>
<th>Next action</th>
<th>Publishing status</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>
<Link href={`/articles/${row.id}`}>{row.workingTitle || row.briefDescription}</Link>
</td>
<td>{row.targetSiteName}</td>
<td>{row.status}</td>
<td>{row.assignedEditor}</td>
<td>{new Date(row.updatedAt).toLocaleString()}</td>
<td>{row.nextAction}</td>
<td>{row.publishingStatus}</td>
</tr>
))}
</tbody>
</table>
);
}
@@ -0,0 +1,42 @@
import type { ArticleDetailResponse, WorkflowEventSummary } from "@pipeline/shared";
export type ArticleTimelineItem = Pick<
WorkflowEventSummary,
"id" | "event_type" | "to_status" | "from_status" | "actor_user_id" | "created_at"
> & {
articleId: string;
};
export type DetailSummary = {
articleId: string;
status: string;
publishingStatus: string;
briefDescription: string;
targetSite: string;
updatedAt: string;
timeline: ArticleTimelineItem[];
};
export function buildArticleTimeline(detail: ArticleDetailResponse): ArticleTimelineItem[] {
return (detail.workflow_events ?? []).map((event) => ({
id: event.id,
event_type: event.event_type,
to_status: event.to_status,
from_status: event.from_status,
actor_user_id: event.actor_user_id,
created_at: event.created_at,
articleId: event.article_id,
}));
}
export function buildDetailSummary(detail: ArticleDetailResponse): DetailSummary {
return {
articleId: detail.article.id,
status: detail.article.status,
publishingStatus: detail.article.publishing_status,
briefDescription: detail.article.brief_description,
targetSite: detail.target_site?.name ?? "Unknown",
updatedAt: detail.article.updated_at,
timeline: buildArticleTimeline(detail),
};
}
@@ -0,0 +1,68 @@
import Link from "next/link";
import type { DetailSummary } from "./model";
type DetailShellProps = {
summary: DetailSummary;
};
export function ArticleDetailShell({ summary }: DetailShellProps) {
const timeline = summary.timeline;
return (
<section>
<h2>Article detail</h2>
<dl className="detailGrid">
<div>
<dt>Article ID</dt>
<dd>{summary.articleId}</dd>
</div>
<div>
<dt>Status</dt>
<dd>{summary.status}</dd>
</div>
<div>
<dt>Publishing status</dt>
<dd>{summary.publishingStatus}</dd>
</div>
<div>
<dt>Target site</dt>
<dd>{summary.targetSite}</dd>
</div>
<div>
<dt>Last updated</dt>
<dd>{new Date(summary.updatedAt).toLocaleString()}</dd>
</div>
</dl>
<h3>Brief</h3>
<p>{summary.briefDescription}</p>
<ul className="inlineLinks">
<li>
<Link href={`/articles/${summary.articleId}/boundary-questions`}>
Boundary questions
</Link>
</li>
<li>
<Link href={`/articles/${summary.articleId}/plans`}>Plan review</Link>
</li>
<li>
<Link href={`/articles/${summary.articleId}/research`}>Research</Link>
</li>
</ul>
<h3>Workflow timeline</h3>
<ul className="timeline">
{timeline.map((event) => (
<li key={event.id}>
<strong>{event.event_type}</strong> from {event.from_status ?? "—"} to{" "}
{event.to_status ?? "—"} at {new Date(event.created_at).toLocaleString()}
{event.actor_user_id ? <span> • actor {event.actor_user_id}</span> : null}
</li>
))}
{timeline.length === 0 ? <li>No workflow events yet.</li> : null}
</ul>
</section>
);
}
@@ -0,0 +1,43 @@
import type { TargetSiteConfig } from "@pipeline/shared";
export type NewArticleDraft = {
targetSiteId: string;
briefDescription: string;
workingTitle: string;
contentType: string;
primaryKeyword: string;
};
export type FieldErrors = Record<string, string>;
export type NewArticleFormState = {
values: NewArticleDraft;
errors: FieldErrors;
formError?: string;
};
export function initialDraft(sites: readonly TargetSiteConfig[]): NewArticleDraft {
return {
targetSiteId: sites.length > 0 ? sites[0].id : "",
briefDescription: "",
workingTitle: "",
contentType: "longform_guide",
primaryKeyword: "",
};
}
export function validateArticleDraft(values: NewArticleDraft): FieldErrors {
const errors: FieldErrors = {};
if (!values.targetSiteId) {
errors.targetSiteId = "Select a target site";
}
if (!values.briefDescription.trim()) {
errors.briefDescription = "Brief description is required";
}
if (!values.contentType.trim()) {
errors.contentType = "Content type is required";
}
return errors;
}
@@ -0,0 +1,131 @@
"use client";
import { useMemo, useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import type { TargetSiteConfig } from "@pipeline/shared";
import { ApiError, createArticle } from "@/shared/pipeline-api";
import {
initialDraft,
validateArticleDraft,
type FieldErrors,
type NewArticleDraft,
} from "./model";
type NewArticleFormProps = {
sites: readonly TargetSiteConfig[];
};
export function NewArticleForm({ sites }: NewArticleFormProps) {
const router = useRouter();
const [values, setValues] = useState<NewArticleDraft>(() => initialDraft(sites));
const [errors, setErrors] = useState<FieldErrors>({});
const [formError, setFormError] = useState("");
const [isSubmitting, setSubmitting] = useState(false);
const canSubmit = useMemo(
() => !isSubmitting && sites.length > 0,
[isSubmitting, sites.length],
);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const nextErrors = validateArticleDraft(values);
if (Object.keys(nextErrors).length > 0) {
setErrors(nextErrors);
return;
}
setErrors({});
setFormError("");
setSubmitting(true);
try {
const response = await createArticle({
target_site_id: values.targetSiteId,
brief_description: values.briefDescription,
working_title: values.workingTitle || null,
language: "en",
content_type: values.contentType,
primary_keyword: values.primaryKeyword || null,
});
router.push(`/articles/${response.article.id}`);
} catch (error) {
if (error instanceof ApiError) {
setFormError(error.message || "Request failed");
} else {
setFormError("Unable to create article");
}
} finally {
setSubmitting(false);
}
}
function updateField<K extends keyof NewArticleDraft>(field: K, value: NewArticleDraft[K]) {
setValues((current) => ({ ...current, [field]: value }));
}
return (
<form className="articleForm" onSubmit={handleSubmit}>
<h2>Create article brief</h2>
{formError ? <p className="formError">Error: {formError}</p> : null}
<label htmlFor="targetSiteId">Target site</label>
<select
id="targetSiteId"
value={values.targetSiteId}
onChange={(event) => updateField("targetSiteId", event.target.value)}
>
<option value="">Select a site</option>
{sites.map((site) => (
<option key={site.id} value={site.id}>
{site.name}
</option>
))}
</select>
{errors.targetSiteId ? <p className="fieldError">{errors.targetSiteId}</p> : null}
<label htmlFor="briefDescription">Brief description</label>
<textarea
id="briefDescription"
value={values.briefDescription}
onChange={(event) => updateField("briefDescription", event.target.value)}
placeholder="Paste editor brief..."
/>
{errors.briefDescription ? (
<p className="fieldError">{errors.briefDescription}</p>
) : null}
<label htmlFor="workingTitle">Working title (optional)</label>
<input
id="workingTitle"
type="text"
value={values.workingTitle}
onChange={(event) => updateField("workingTitle", event.target.value)}
placeholder="Optional working title"
/>
<label htmlFor="contentType">Content type</label>
<input
id="contentType"
type="text"
value={values.contentType}
onChange={(event) => updateField("contentType", event.target.value)}
/>
{errors.contentType ? <p className="fieldError">{errors.contentType}</p> : null}
<label htmlFor="primaryKeyword">Primary keyword (optional)</label>
<input
id="primaryKeyword"
type="text"
value={values.primaryKeyword}
onChange={(event) => updateField("primaryKeyword", event.target.value)}
/>
<div className="formActions">
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? "Creating..." : "Create article"}
</button>
</div>
</form>
);
}
@@ -0,0 +1,39 @@
import type { BoundaryQuestionSummary } from "@pipeline/shared";
export type BoundaryQuestionFormRow = {
id: string;
category: string;
question: string;
answer: string;
isRequired: boolean;
stateLabel: string;
};
export type BoundaryQuestionErrors = Record<string, string>;
export function buildBoundaryQuestionRows(
questions: readonly BoundaryQuestionSummary[],
): BoundaryQuestionFormRow[] {
return questions.map((question) => ({
id: question.id,
category: question.category,
question: question.question,
answer: question.answer ?? "",
isRequired: question.is_required ?? true,
stateLabel: question.is_required === false ? "Optional" : "Required",
}));
}
export function validateRequiredAnswers(
rows: readonly BoundaryQuestionFormRow[],
): BoundaryQuestionErrors {
const errors: BoundaryQuestionErrors = {};
for (const row of rows) {
if (row.isRequired && !row.answer.trim()) {
errors[row.id] = "Required answer";
}
}
return errors;
}
@@ -0,0 +1,157 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import type { BoundaryQuestionSummary } from "@pipeline/shared";
import { ApiError, generateBoundaryQuestions, submitBoundaryQuestions, updateBoundaryQuestion } from "@/shared/pipeline-api";
import {
buildBoundaryQuestionRows,
validateRequiredAnswers,
type BoundaryQuestionErrors,
type BoundaryQuestionFormRow,
} from "./model";
type BoundaryQuestionsEditorProps = {
articleId: string;
initialQuestions: readonly BoundaryQuestionSummary[];
};
export function BoundaryQuestionsEditor({
articleId,
initialQuestions,
}: BoundaryQuestionsEditorProps) {
const router = useRouter();
const [rows, setRows] = useState<BoundaryQuestionFormRow[]>(() =>
buildBoundaryQuestionRows(initialQuestions),
);
const [errors, setErrors] = useState<BoundaryQuestionErrors>({});
const [formMessage, setFormMessage] = useState("");
const [isBusy, setBusy] = useState(false);
const requiredProgress = useMemo(() => {
const requiredRows = rows.filter((row) => row.isRequired);
const answeredRows = requiredRows.filter((row) => row.answer.trim());
return `${answeredRows.length}/${requiredRows.length}`;
}, [rows]);
function updateRow(questionId: string, answer: string) {
setRows((current) =>
current.map((row) => (row.id === questionId ? { ...row, answer } : row)),
);
}
async function handleGenerate() {
setBusy(true);
setFormMessage("");
try {
const response = await generateBoundaryQuestions(articleId);
setRows(buildBoundaryQuestionRows(response.questions));
setErrors({});
router.refresh();
} catch (error) {
setFormMessage(error instanceof ApiError ? error.message : "Unable to generate questions");
} finally {
setBusy(false);
}
}
async function handleSave(row: BoundaryQuestionFormRow) {
setBusy(true);
setFormMessage("");
try {
const response = await updateBoundaryQuestion(articleId, row.id, {
answer: row.answer.trim() || null,
});
setRows((current) =>
current.map((candidate) =>
candidate.id === row.id
? buildBoundaryQuestionRows([response.question])[0]
: candidate,
),
);
setErrors((current) => {
const next = { ...current };
delete next[row.id];
return next;
});
} catch (error) {
setFormMessage(error instanceof ApiError ? error.message : "Unable to save answer");
} finally {
setBusy(false);
}
}
async function handleSubmit() {
const nextErrors = validateRequiredAnswers(rows);
if (Object.keys(nextErrors).length > 0) {
setErrors(nextErrors);
return;
}
setBusy(true);
setFormMessage("");
try {
await submitBoundaryQuestions(articleId);
router.push(`/articles/${articleId}`);
router.refresh();
} catch (error) {
setFormMessage(error instanceof ApiError ? error.message : "Unable to submit answers");
} finally {
setBusy(false);
}
}
return (
<section className="boundaryQuestions">
<div className="sectionHeader">
<div>
<h2>Boundary questions</h2>
<p>Required answers: {requiredProgress}</p>
</div>
<button type="button" onClick={handleGenerate} disabled={isBusy}>
{rows.length > 0 ? "Regenerate" : "Generate"}
</button>
</div>
{formMessage ? <p className="formError">Error: {formMessage}</p> : null}
{rows.length === 0 ? (
<p className="emptyState">No boundary questions generated yet.</p>
) : (
<div className="questionList">
{rows.map((row) => (
<article className="questionItem" key={row.id}>
<div className="questionHeader">
<div>
<strong>{row.category.replaceAll("_", " ")}</strong>
<p>{row.question}</p>
</div>
<span className={row.isRequired ? "requiredBadge" : "optionalBadge"}>
{row.stateLabel}
</span>
</div>
<textarea
value={row.answer}
onChange={(event) => updateRow(row.id, event.target.value)}
/>
{errors[row.id] ? <p className="fieldError">{errors[row.id]}</p> : null}
<div className="formActions">
<button type="button" onClick={() => handleSave(row)} disabled={isBusy}>
Save
</button>
</div>
</article>
))}
</div>
)}
<div className="formActions">
<button type="button" onClick={handleSubmit} disabled={isBusy || rows.length === 0}>
Submit boundary answers
</button>
</div>
</section>
);
}
@@ -0,0 +1,37 @@
import type { PlanSummary } from "@pipeline/shared";
export type PlanReviewView = {
id: string;
version: number;
status: string;
recommendedTitle: string;
readerPersona: string;
searchIntent: string;
sectionCount: number;
evidenceNeeds: string;
visualNeeds: string;
seoNotes: string;
sourceRequirements: string;
excludedSources: string;
tone: string;
audience: string;
};
export function buildPlanReviewViews(plans: readonly PlanSummary[]): PlanReviewView[] {
return plans.map((plan) => ({
id: plan.id,
version: plan.version,
status: plan.status,
recommendedTitle: plan.recommended_title ?? "",
readerPersona: plan.reader_persona ?? "",
searchIntent: plan.search_intent ?? "",
sectionCount: plan.sections?.length ?? 0,
evidenceNeeds: (plan.evidence_needs ?? []).join(", "),
visualNeeds: (plan.visual_needs ?? []).join(", "),
seoNotes: (plan.seo_notes ?? []).join(", "),
sourceRequirements: (plan.source_requirements ?? []).join(", "),
excludedSources: (plan.excluded_sources ?? []).join(", "),
tone: plan.tone ?? "",
audience: plan.audience ?? "",
}));
}
@@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { PlanSummary } from "@pipeline/shared";
import {
ApiError,
approveArticlePlan,
generateArticlePlan,
requestArticlePlanRevision,
updateArticlePlan,
} from "@/shared/pipeline-api";
import { buildPlanReviewViews } from "./model";
type PlanReviewPanelProps = {
articleId: string;
initialPlans: readonly PlanSummary[];
};
export function PlanReviewPanel({ articleId, initialPlans }: PlanReviewPanelProps) {
const router = useRouter();
const [plans, setPlans] = useState<PlanSummary[]>([...initialPlans]);
const [message, setMessage] = useState("");
const [isBusy, setBusy] = useState(false);
const views = buildPlanReviewViews(plans);
const currentPlan = plans.at(-1) ?? null;
async function runAction(action: () => Promise<unknown>) {
setBusy(true);
setMessage("");
try {
await action();
router.refresh();
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Plan action failed");
} finally {
setBusy(false);
}
}
function handleGenerate() {
void runAction(async () => {
const response = await generateArticlePlan(articleId);
setPlans((current) => [...current, response.plan]);
});
}
function handleEdit() {
if (!currentPlan) {
return;
}
void runAction(async () => {
const response = await updateArticlePlan(articleId, currentPlan.id, {
recommended_title: `${currentPlan.recommended_title ?? "Plan"} edited`,
tone: currentPlan.tone ?? "Evidence-led",
audience: currentPlan.audience ?? currentPlan.reader_persona,
seo_notes: [...(currentPlan.seo_notes ?? []), "Editor adjustment"],
source_requirements: currentPlan.source_requirements ?? [],
excluded_sources: currentPlan.excluded_sources ?? [],
visual_needs: currentPlan.visual_needs ?? [],
});
setPlans((current) => [...current, response.plan]);
});
}
function handleRevision() {
if (!currentPlan) {
return;
}
void runAction(() =>
requestArticlePlanRevision(articleId, currentPlan.id, {
notes: "Revise plan scope and evidence standard.",
}),
);
}
function handleApprove() {
if (!currentPlan) {
return;
}
void runAction(() => approveArticlePlan(articleId, currentPlan.id));
}
return (
<section>
<div className="sectionHeader">
<div>
<h2>Plan review</h2>
<p>{plans.length} versions</p>
</div>
<button type="button" onClick={handleGenerate} disabled={isBusy}>
Generate
</button>
</div>
{message ? <p className="formError">Error: {message}</p> : null}
{views.length === 0 ? (
<p className="emptyState">No plans generated yet.</p>
) : (
<table className="table">
<thead>
<tr>
<th>Version</th>
<th>Plan</th>
<th>Requirements</th>
<th>Controls</th>
</tr>
</thead>
<tbody>
{views.map((view) => (
<tr key={view.id}>
<td>
<strong>v{view.version}</strong>
<div>{view.status}</div>
</td>
<td>
<div>{view.recommendedTitle}</div>
<div>{view.readerPersona}</div>
<div>{view.searchIntent}</div>
<div>{view.sectionCount} sections</div>
</td>
<td>
<div>Sources: {view.sourceRequirements}</div>
<div>Excluded: {view.excludedSources}</div>
<div>Visuals: {view.visualNeeds}</div>
<div>SEO: {view.seoNotes}</div>
</td>
<td>
<div>Tone: {view.tone}</div>
<div>Audience: {view.audience}</div>
</td>
</tr>
))}
</tbody>
</table>
)}
<div className="formActions">
<button type="button" onClick={handleEdit} disabled={isBusy || !currentPlan}>
Edit
</button>
<button type="button" onClick={handleRevision} disabled={isBusy || !currentPlan}>
Request revision
</button>
<button type="button" onClick={handleApprove} disabled={isBusy || !currentPlan}>
Approve
</button>
</div>
</section>
);
}
@@ -0,0 +1,40 @@
import type { ResearchArtifactManifestSummary } from "@pipeline/shared";
export type ResearchSourceRow = {
manifestId: string;
sourceUrl: string;
title: string;
domain: string;
type: string;
summary: string;
retrievedAt: string;
artifactLink: string;
objectKey: string;
contentHash: string;
};
function stringValue(value: unknown): string {
return typeof value === "string" ? value : "";
}
export function buildResearchSourceRows(
manifests: readonly ResearchArtifactManifestSummary[],
): ResearchSourceRow[] {
return manifests.flatMap((manifest) =>
(manifest.artifacts ?? []).map((artifact) => {
const metadata = artifact.metadata ?? {};
return {
manifestId: manifest.id,
sourceUrl: artifact.source_url,
title: stringValue(metadata.source_title),
domain: stringValue(metadata.domain),
type: stringValue(metadata.source_type) || artifact.artifact_type,
summary: stringValue(metadata.summary),
retrievedAt: stringValue(metadata.retrieved_at) || manifest.created_at,
artifactLink: stringValue(metadata.artifact_link) || artifact.object_key,
objectKey: artifact.object_key,
contentHash: artifact.content_hash,
};
}),
);
}
@@ -0,0 +1,87 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { ResearchArtifactManifestSummary } from "@pipeline/shared";
import { ApiError, startResearch } from "@/shared/pipeline-api";
import { buildResearchSourceRows } from "./model";
type ResearchManifestPanelProps = {
articleId: string;
initialManifests: readonly ResearchArtifactManifestSummary[];
};
export function ResearchManifestPanel({
articleId,
initialManifests,
}: ResearchManifestPanelProps) {
const router = useRouter();
const [manifests, setManifests] = useState<ResearchArtifactManifestSummary[]>([
...initialManifests,
]);
const [message, setMessage] = useState("");
const [isBusy, setBusy] = useState(false);
const rows = buildResearchSourceRows(manifests);
async function handleStart() {
setBusy(true);
setMessage("");
try {
const response = await startResearch(articleId);
setManifests((current) => [...current, response.manifest]);
router.refresh();
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Unable to start research");
} finally {
setBusy(false);
}
}
return (
<section>
<div className="sectionHeader">
<div>
<h2>Research</h2>
<p>{manifests.length} runs</p>
</div>
<button type="button" onClick={handleStart} disabled={isBusy}>
Start research
</button>
</div>
{message ? <p className="formError">Error: {message}</p> : null}
{rows.length === 0 ? (
<p className="emptyState">No research sources yet.</p>
) : (
<table className="table">
<thead>
<tr>
<th>Source</th>
<th>Summary</th>
<th>Retrieved</th>
<th>Artifact</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={`${row.manifestId}-${row.objectKey}`}>
<td>
<div>{row.title}</div>
<a href={row.sourceUrl}>{row.domain || row.sourceUrl}</a>
<div>{row.type}</div>
</td>
<td>{row.summary}</td>
<td>{new Date(row.retrievedAt).toLocaleString()}</td>
<td>
<div>{row.artifactLink}</div>
<small>{row.contentHash}</small>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}
@@ -0,0 +1,28 @@
import Link from "next/link";
import { AgentJobsTable } from "@/features/agent-jobs/ui";
import { buildAgentJobRows } from "@/features/agent-jobs/model";
import { fetchAgentJobs } from "@/shared/pipeline-api";
import { RoleNavigation } from "@/widgets/role-navigation";
export default async function AdminJobsPage() {
const jobs = await fetchAgentJobs();
const rows = buildAgentJobRows(jobs.jobs);
return (
<main>
<header className="pageHeader">
<div>
<h1>Agent Jobs</h1>
<p>Monitor runner status, logs, outputs, and retry lineage.</p>
</div>
<Link href="/">Back to dashboard</Link>
<span className="roleBadge">Admin demo</span>
</header>
<RoleNavigation role="ADMIN" />
<section className="panel">
<AgentJobsTable rows={rows} />
</section>
</main>
);
}
@@ -0,0 +1,84 @@
import Link from "next/link";
import { DEMO_ADMIN_EMAIL, fetchScriptConfigVersions, fetchTargetSitesAsUser, fetchTargetSite } from "@/shared/pipeline-api";
import { RoleNavigation } from "@/widgets/role-navigation";
import { AdminScriptVersionsPanel } from "@/features/admin-scripts/ui";
type SearchParams = {
siteId?: string;
};
type AdminScriptsPageProps = {
searchParams?: SearchParams;
};
export default async function AdminScriptsPage({
searchParams,
}: AdminScriptsPageProps) {
const sitesResponse = await fetchTargetSitesAsUser(DEMO_ADMIN_EMAIL);
const siteOptions = sitesResponse.map((site) => site.site);
if (siteOptions.length === 0) {
return (
<main>
<header className="pageHeader">
<div>
<h1>Admin Script Versions</h1>
<p>No target sites configured yet.</p>
</div>
<Link href="/admin/sites">Open site configuration</Link>
</header>
<RoleNavigation role="ADMIN" />
</main>
);
}
const selectedSiteId =
searchParams?.siteId ?? siteOptions[0]?.id;
const selectedSite =
siteOptions.find((site) => site.id === selectedSiteId) ?? siteOptions[0];
const selectedResponse = await fetchTargetSite(selectedSite.id, DEMO_ADMIN_EMAIL);
const activeSite = selectedResponse.site;
const versionsResponse = await fetchScriptConfigVersions(
selectedSite.id,
DEMO_ADMIN_EMAIL,
);
return (
<main>
<header className="pageHeader">
<div>
<h1>Admin Script Versions</h1>
<p>Manage publishing YAML + transform scripts for each site.</p>
</div>
<Link href="/admin/sites">Open site configuration</Link>
</header>
<RoleNavigation role="ADMIN" />
<section className="panel">
<h2>Select site</h2>
<ul className="inlineLinks">
{siteOptions.map((site) => {
const isActive = site.id === activeSite.id;
return (
<li key={site.id}>
<Link
href={`/admin/scripts?siteId=${site.id}`}
className={isActive ? "activeLink" : undefined}
>
{site.name}
</Link>
</li>
);
})}
</ul>
</section>
<section className="panel">
<AdminScriptVersionsPanel
site={activeSite}
initialVersions={versionsResponse.versions}
/>
</section>
</main>
);
}
@@ -0,0 +1,35 @@
import Link from "next/link";
import { DEMO_ADMIN_EMAIL } from "@/shared/pipeline-api";
import { AdminSiteCreateForm } from "@/features/admin-site-config-create/ui";
import { buildAdminSiteRows } from "@/features/admin-site-config/model";
import { AdminSiteConfigTable } from "@/features/admin-site-config/ui";
import { RoleNavigation } from "@/widgets/role-navigation";
import { fetchTargetSitesAsUser } from "@/shared/pipeline-api";
export default async function AdminSitesPage() {
const sitesResponse = await fetchTargetSitesAsUser(DEMO_ADMIN_EMAIL);
const sites = sitesResponse.map((site) => site.site);
const rows = buildAdminSiteRows(sites);
return (
<main>
<header className="pageHeader">
<div>
<h1>Admin Site Config</h1>
<p>Manage target site publishing settings.</p>
</div>
<Link href="/">Back to dashboard</Link>
<span className="roleBadge">Admin demo</span>
</header>
<RoleNavigation role="ADMIN" />
<section className="panel">
<h2>Existing sites</h2>
<AdminSiteConfigTable rows={rows} />
</section>
<section className="panel">
<AdminSiteCreateForm />
</section>
</main>
);
}
@@ -0,0 +1,34 @@
import Link from "next/link";
import { BoundaryQuestionsEditor } from "@/features/boundary-questions/ui";
import { fetchBoundaryQuestions } from "@/shared/pipeline-api";
import { RoleNavigation } from "@/widgets/role-navigation";
type ArticleBoundaryQuestionsPageProps = {
articleId: string;
};
export default async function ArticleBoundaryQuestionsPage({
articleId,
}: ArticleBoundaryQuestionsPageProps) {
const response = await fetchBoundaryQuestions(articleId);
return (
<main>
<header className="pageHeader">
<div>
<h1>Boundary questions</h1>
<p>{response.questions.length} questions</p>
</div>
<Link href={`/articles/${articleId}`}>Back to article</Link>
</header>
<RoleNavigation role="EDITOR" />
<section className="panel">
<BoundaryQuestionsEditor
articleId={articleId}
initialQuestions={response.questions}
/>
</section>
</main>
);
}
@@ -0,0 +1,55 @@
import Link from "next/link";
import { RoleNavigation } from "@/widgets/role-navigation";
import {
buildDetailSummary,
} from "@/features/article-detail/model";
import { ArticleDetailShell } from "@/features/article-detail/ui";
import { fetchArticleDetail } from "@/shared/pipeline-api";
import { ApiError } from "@/shared/pipeline-api";
type ArticleDetailPageProps = {
articleId: string;
};
export default async function ArticleDetailPage({
articleId,
}: ArticleDetailPageProps) {
try {
const detail = await fetchArticleDetail(articleId);
const summary = buildDetailSummary(detail);
return (
<main>
<header className="pageHeader">
<div>
<h1>Article detail</h1>
<p>{summary.status}</p>
</div>
<Link href="/">Back to dashboard</Link>
</header>
<RoleNavigation role="EDITOR" />
<section className="panel">
<ArticleDetailShell summary={summary} />
</section>
</main>
);
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
return (
<main>
<header className="pageHeader">
<h1>Article not found</h1>
<Link href="/">Back to dashboard</Link>
</header>
<RoleNavigation role="EDITOR" />
<section className="panel">
<p>The requested article does not exist.</p>
</section>
</main>
);
}
throw error;
}
}
@@ -0,0 +1,27 @@
import type { TargetSiteConfigResponse } from "@pipeline/shared";
import Link from "next/link";
import { RoleNavigation } from "@/widgets/role-navigation";
import { NewArticleForm } from "@/features/article-intake/ui";
import { fetchTargetSites } from "@/shared/pipeline-api";
export default async function NewArticlePage() {
const sites: TargetSiteConfigResponse[] = await fetchTargetSites();
const targetSites = sites.map((site) => site.site);
return (
<main>
<header className="pageHeader">
<div>
<h1>New Article</h1>
<p>Draft a new editorial brief for an existing site.</p>
</div>
<Link href="/">Back to dashboard</Link>
</header>
<RoleNavigation role="EDITOR" />
<section className="panel">
<NewArticleForm sites={targetSites} />
</section>
</main>
);
}
@@ -0,0 +1,29 @@
import Link from "next/link";
import { PlanReviewPanel } from "@/features/plan-review/ui";
import { fetchArticlePlans } from "@/shared/pipeline-api";
import { RoleNavigation } from "@/widgets/role-navigation";
type ArticlePlansPageProps = {
articleId: string;
};
export default async function ArticlePlansPage({ articleId }: ArticlePlansPageProps) {
const response = await fetchArticlePlans(articleId);
return (
<main>
<header className="pageHeader">
<div>
<h1>Plan review</h1>
<p>{response.plans.length} versions</p>
</div>
<Link href={`/articles/${articleId}`}>Back to article</Link>
</header>
<RoleNavigation role="EDITOR" />
<section className="panel">
<PlanReviewPanel articleId={articleId} initialPlans={response.plans} />
</section>
</main>
);
}
@@ -0,0 +1,34 @@
import Link from "next/link";
import { ResearchManifestPanel } from "@/features/research-manifest/ui";
import { fetchResearch } from "@/shared/pipeline-api";
import { RoleNavigation } from "@/widgets/role-navigation";
type ArticleResearchPageProps = {
articleId: string;
};
export default async function ArticleResearchPage({
articleId,
}: ArticleResearchPageProps) {
const response = await fetchResearch(articleId);
return (
<main>
<header className="pageHeader">
<div>
<h1>Research</h1>
<p>{response.manifests?.length ?? 0} runs</p>
</div>
<Link href={`/articles/${articleId}`}>Back to article</Link>
</header>
<RoleNavigation role="EDITOR" />
<section className="panel">
<ResearchManifestPanel
articleId={articleId}
initialManifests={response.manifests ?? []}
/>
</section>
</main>
);
}
@@ -0,0 +1,37 @@
import Link from "next/link";
import type { ArticleListResponse, TargetSiteConfigResponse } from "@pipeline/shared";
import { RoleNavigation } from "@/widgets/role-navigation";
import { buildDashboardRows } from "@/features/article-dashboard/model";
import { DashboardTable } from "@/features/article-dashboard/ui";
import { fetchArticles, fetchTargetSites } from "@/shared/pipeline-api";
export default async function DashboardPage() {
const [articlesResponse, sitesResponse]: [
ArticleListResponse,
TargetSiteConfigResponse[],
] = await Promise.all([fetchArticles(), fetchTargetSites()]);
const rows = buildDashboardRows(
articlesResponse.articles,
sitesResponse.map((site) => site.site),
);
return (
<main>
<header className="pageHeader">
<div>
<h1>AI Content Pipeline</h1>
<p>{rows.length} active article workflows</p>
</div>
<Link href="/articles/new">New article</Link>
<span className="roleBadge">Editor demo</span>
</header>
<RoleNavigation role="EDITOR" />
<section className="panel">
<DashboardTable rows={rows} />
</section>
</main>
);
}
+388
View File
@@ -0,0 +1,388 @@
import type {
ArticleCreateRequest,
ArticleCreateResponse,
ArticleDetailResponse,
AgentJobListResponse,
AgentJobResponse,
AgentJobTestCodexRequest,
ArticleListResponse,
BoundaryQuestionListResponse,
BoundaryQuestionResponse,
BoundaryQuestionUpdateRequest,
CurrentUserResponse,
PlanListResponse,
PlanResponse,
PlanRevisionRequest,
PlanUpdateRequest,
ResearchListResponse,
ResearchStartResponse,
ReviewActionResponse,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse,
ScriptConfigVersionResponse,
TargetSiteConfigResponse,
TargetSiteConfigCreateRequest,
} from "@pipeline/shared";
export const DEMO_EDITOR_EMAIL = "editor@example.com";
export const DEMO_ADMIN_EMAIL = "admin@example.com";
const DEFAULT_LOCAL_BACKEND_URL = "http://localhost:8000";
type ApiRequestConfig = {
userEmail?: string;
};
export type ApiErrorPayload = {
status: number;
message: string;
detail?: unknown;
};
export class ApiError extends Error {
status: number;
detail?: unknown;
constructor(payload: ApiErrorPayload) {
super(payload.message);
this.name = "ApiError";
this.status = payload.status;
this.detail = payload.detail;
}
}
function resolveBackendBaseUrl(): string {
if (typeof window === "undefined") {
return process.env.BACKEND_URL ?? process.env.NEXT_PUBLIC_BACKEND_URL ?? DEFAULT_LOCAL_BACKEND_URL;
}
return (
process.env.NEXT_PUBLIC_BACKEND_URL ??
process.env.BACKEND_URL ??
""
);
}
function buildApiUrl(path: string): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const base = resolveBackendBaseUrl();
if (!base || base === "/") {
return normalizedPath;
}
return `${base.replace(/\/$/, "")}${normalizedPath}`;
}
async function parseBody(response: Response): Promise<unknown> {
const text = await response.text();
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch {
return text;
}
}
async function requestJson<T>(
path: string,
options: RequestInit = {},
userEmail?: string,
): Promise<T> {
const isFormData = options.body instanceof FormData;
const effectiveUserEmail = userEmail ?? DEMO_EDITOR_EMAIL;
const response = await fetch(buildApiUrl(path), {
...options,
headers: {
...(!isFormData
? { "Content-Type": "application/json" }
: {}),
"X-Demo-User-Email": effectiveUserEmail,
...(options.headers ?? {}),
},
cache: options.cache ?? "no-store",
});
const body = await parseBody(response);
if (!response.ok) {
const message =
typeof body === "object" && body !== null && "detail" in body
? String((body as { detail?: unknown }).detail)
: response.statusText;
throw new ApiError({
status: response.status,
message,
detail: body,
});
}
return body as T;
}
export function apiGet<T>(
path: string,
options?: ApiRequestConfig,
): Promise<T> {
return requestJson<T>(path, { method: "GET" }, options?.userEmail);
}
export function apiPost<TRequest, TResponse>(
path: string,
body: TRequest | undefined,
options?: ApiRequestConfig,
): Promise<TResponse> {
const requestBody = body === undefined ? undefined : JSON.stringify(body);
return requestJson<TResponse>(
path,
{
method: "POST",
...(requestBody === undefined ? {} : { body: requestBody }),
},
options?.userEmail,
);
}
export function apiPatch<TRequest, TResponse>(
path: string,
body: TRequest,
options?: ApiRequestConfig,
): Promise<TResponse> {
return requestJson<TResponse>(
path,
{
method: "PATCH",
body: JSON.stringify(body),
},
options?.userEmail,
);
}
export function fetchCurrentUser(): Promise<CurrentUserResponse> {
return apiGet<CurrentUserResponse>("/api/me");
}
export function fetchAgentJobs(
userEmail = DEMO_ADMIN_EMAIL,
): Promise<AgentJobListResponse> {
return apiGet<AgentJobListResponse>("/api/agent-jobs", { userEmail });
}
export function createTestCodexJob(
request: AgentJobTestCodexRequest = { fake_result: "valid" },
userEmail = DEMO_ADMIN_EMAIL,
): Promise<AgentJobResponse> {
return apiPost<AgentJobTestCodexRequest, AgentJobResponse>(
"/api/agent-jobs/test-codex",
request,
{ userEmail },
);
}
export function retryAgentJob(
jobId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<AgentJobResponse> {
return apiPost<unknown, AgentJobResponse>(
`/api/agent-jobs/${jobId}/retry`,
undefined,
{ userEmail },
);
}
export function cancelAgentJob(
jobId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<AgentJobResponse> {
return apiPost<unknown, AgentJobResponse>(
`/api/agent-jobs/${jobId}/cancel`,
undefined,
{ userEmail },
);
}
export function fetchTargetSites(): Promise<TargetSiteConfigResponse[]> {
return apiGet<TargetSiteConfigResponse[]>("/api/sites");
}
export function fetchTargetSitesAsUser(
userEmail: string,
): Promise<TargetSiteConfigResponse[]> {
return apiGet<TargetSiteConfigResponse[]>("/api/sites", { userEmail });
}
export function fetchTargetSite(
siteId: string,
userEmail = DEMO_EDITOR_EMAIL,
): Promise<TargetSiteConfigResponse> {
return apiGet<TargetSiteConfigResponse>(`/api/sites/${siteId}`, {
userEmail,
});
}
export function createTargetSite(
request: TargetSiteConfigCreateRequest,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<TargetSiteConfigResponse> {
return apiPost<TargetSiteConfigCreateRequest, TargetSiteConfigResponse>(
"/api/sites",
request,
{ userEmail },
);
}
export function fetchScriptConfigVersions(
siteId: string,
userEmail = DEMO_EDITOR_EMAIL,
): Promise<ScriptConfigVersionListResponse> {
return apiGet<ScriptConfigVersionListResponse>(
`/api/sites/${siteId}/publishing-config/versions`,
{ userEmail },
);
}
export function createScriptConfigVersion(
siteId: string,
request: ScriptConfigVersionCreateRequest,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<ScriptConfigVersionResponse> {
return apiPost<ScriptConfigVersionCreateRequest, ScriptConfigVersionResponse>(
`/api/sites/${siteId}/publishing-config/versions`,
request,
{ userEmail },
);
}
export function activateScriptConfigVersion(
siteId: string,
versionId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<ScriptConfigVersionResponse> {
return apiPost<unknown, ScriptConfigVersionResponse>(
`/api/sites/${siteId}/publishing-config/versions/${versionId}/activate`,
undefined,
{ userEmail },
);
}
export function rollbackScriptConfigVersion(
siteId: string,
versionId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<ScriptConfigVersionResponse> {
return apiPost<unknown, ScriptConfigVersionResponse>(
`/api/sites/${siteId}/publishing-config/versions/${versionId}/rollback`,
undefined,
{ userEmail },
);
}
export function fetchArticles(): Promise<ArticleListResponse> {
return apiGet<ArticleListResponse>("/api/articles");
}
export function fetchArticleDetail(
articleId: string,
): Promise<ArticleDetailResponse> {
return apiGet<ArticleDetailResponse>(`/api/articles/${articleId}`);
}
export function createArticle(
request: ArticleCreateRequest,
): Promise<ArticleCreateResponse> {
return apiPost<ArticleCreateRequest, ArticleCreateResponse>(
"/api/articles",
request,
);
}
export function generateBoundaryQuestions(
articleId: string,
): Promise<BoundaryQuestionListResponse> {
return apiPost<unknown, BoundaryQuestionListResponse>(
`/api/articles/${articleId}/boundary-questions/generate`,
undefined,
);
}
export function fetchBoundaryQuestions(
articleId: string,
): Promise<BoundaryQuestionListResponse> {
return apiGet<BoundaryQuestionListResponse>(
`/api/articles/${articleId}/boundary-questions`,
);
}
export function updateBoundaryQuestion(
articleId: string,
questionId: string,
request: BoundaryQuestionUpdateRequest,
): Promise<BoundaryQuestionResponse> {
return apiPatch<BoundaryQuestionUpdateRequest, BoundaryQuestionResponse>(
`/api/articles/${articleId}/boundary-questions/${questionId}`,
request,
);
}
export function submitBoundaryQuestions(
articleId: string,
): Promise<ArticleCreateResponse> {
return apiPost<unknown, ArticleCreateResponse>(
`/api/articles/${articleId}/boundary-questions/submit`,
undefined,
);
}
export function generateArticlePlan(articleId: string): Promise<PlanResponse> {
return apiPost<unknown, PlanResponse>(
`/api/articles/${articleId}/plan/generate`,
undefined,
);
}
export function fetchArticlePlans(articleId: string): Promise<PlanListResponse> {
return apiGet<PlanListResponse>(`/api/articles/${articleId}/plans`);
}
export function updateArticlePlan(
articleId: string,
planId: string,
request: PlanUpdateRequest,
): Promise<PlanResponse> {
return apiPatch<PlanUpdateRequest, PlanResponse>(
`/api/articles/${articleId}/plans/${planId}`,
request,
);
}
export function approveArticlePlan(
articleId: string,
planId: string,
): Promise<ReviewActionResponse> {
return apiPost<unknown, ReviewActionResponse>(
`/api/articles/${articleId}/plans/${planId}/approve`,
undefined,
);
}
export function requestArticlePlanRevision(
articleId: string,
planId: string,
request: PlanRevisionRequest,
): Promise<ReviewActionResponse> {
return apiPost<PlanRevisionRequest, ReviewActionResponse>(
`/api/articles/${articleId}/plans/${planId}/request-revision`,
request,
);
}
export function startResearch(articleId: string): Promise<ResearchStartResponse> {
return apiPost<unknown, ResearchStartResponse>(
`/api/articles/${articleId}/research/start`,
undefined,
);
}
export function fetchResearch(articleId: string): Promise<ResearchListResponse> {
return apiGet<ResearchListResponse>(`/api/articles/${articleId}/research`);
}
@@ -0,0 +1,3 @@
export { getRoleNavigationItems, ROLE_NAVIGATION_ITEMS } from "./model";
export type { NavigationItem } from "./model";
export { RoleNavigation } from "./ui";
@@ -0,0 +1,53 @@
import type { Role } from "@pipeline/shared";
export type NavigationItem = {
id: string;
label: string;
href: string;
roles: readonly Role[];
};
export const ROLE_NAVIGATION_ITEMS = [
{
id: "dashboard",
label: "Dashboard",
href: "/",
roles: ["ADMIN", "EDITOR"],
},
{
id: "new-article",
label: "New article",
href: "/articles/new",
roles: ["ADMIN", "EDITOR"],
},
{
id: "review",
label: "Review",
href: "/review",
roles: ["ADMIN", "EDITOR"],
},
{
id: "site-config",
label: "Site configuration",
href: "/admin/sites",
roles: ["ADMIN"],
},
{
id: "script-versions",
label: "Script versions",
href: "/admin/scripts",
roles: ["ADMIN"],
},
{
id: "agent-jobs",
label: "Agent jobs",
href: "/admin/jobs",
roles: ["ADMIN"],
},
] as const satisfies readonly NavigationItem[];
export function getRoleNavigationItems(role: Role): NavigationItem[] {
return ROLE_NAVIGATION_ITEMS.filter((item) =>
item.roles.some((allowedRole) => allowedRole === role),
);
}
@@ -0,0 +1,23 @@
import type { Role } from "@pipeline/shared";
import { getRoleNavigationItems } from "./model";
type RoleNavigationProps = {
role: Role;
};
export function RoleNavigation({ role }: RoleNavigationProps) {
const items = getRoleNavigationItems(role);
return (
<nav aria-label="Primary navigation">
<ul className="roleNavigation">
{items.map((item) => (
<li key={item.id}>
<a href={item.href}>{item.label}</a>
</li>
))}
</ul>
</nav>
);
}
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const testDir = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(
testDir,
"../src/features/admin-scripts/model.ts",
);
const source = readFileSync(sourcePath, "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
});
const moduleExports = {};
new Function("exports", compiled.outputText)(moduleExports);
const {
buildAdminScriptVersionRows,
defaultScriptVersionDraft,
buildCreateScriptVersionPayload,
parseJsonObject,
} = moduleExports;
const rows = buildAdminScriptVersionRows([
{
id: "11111111-1111-1111-1111-111111111101",
target_site_id: "22222222-2222-2222-2222-222222222222",
version: 1,
status: "ACTIVE",
diff: { summary: "seed" },
publishing_yaml_hash: "abc",
transform_script_hash: "def",
rollback_target_version_id: null,
created_by: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
created_at: "2025-01-01T00:00:00Z",
activated_at: "2025-01-01T01:00:00Z",
},
]);
assert.equal(rows.length, 1);
assert.equal(rows[0].isActive, true);
assert.equal(rows[0].diff, '{"summary":"seed"}');
const payload = buildCreateScriptVersionPayload(defaultScriptVersionDraft());
assert.equal(payload.activate, false);
assert.ok(typeof payload.publishing_yaml === "string");
assert.equal(payload.rollback_target_version_id, null);
assert.deepEqual(parseJsonObject('{"a":1}'), { a: 1 });
assert.equal(parseJsonObject("[]"), null);
assert.deepEqual(parseJsonObject(""), {});
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const testDir = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(testDir, "../src/features/agent-jobs/model.ts");
const source = readFileSync(sourcePath, "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
});
const moduleExports = {};
new Function("exports", compiled.outputText)(moduleExports);
const { buildAgentJobRows } = moduleExports;
const rows = buildAgentJobRows([
{
id: "11111111-1111-1111-1111-111111111111",
article_id: null,
parent_job_id: "00000000-0000-0000-0000-000000000000",
attempt: 2,
job_type: "TEST_CODEX",
agent_profile: "fake-codex",
status: "SUCCEEDED",
workspace_path: "/tmp/job",
input_files: [{ path: "inputs/job.json" }],
output_files: [{ path: "outputs/result.json" }],
error_category: null,
error_message: null,
stdout: "done",
stderr: "",
exit_code: 0,
duration_ms: 5,
queued_at: "2026-05-21T00:00:00Z",
started_at: "2026-05-21T00:00:01Z",
finished_at: "2026-05-21T00:00:02Z",
},
]);
assert.equal(rows.length, 1);
assert.equal(rows[0].status, "SUCCEEDED");
assert.equal(rows[0].attempt, 2);
assert.equal(rows[0].parentJobId, "00000000-0000-0000-0000-000000000000");
assert.equal(rows[0].outputFiles, "outputs/result.json");
assert.equal(rows[0].stdout, "done");

Some files were not shown because too many files have changed in this diff Show More