Compare commits

..
25 Commits
Author SHA1 Message Date
huncode b8937d2864 fix(task-024): make demo browser flow reachable 2026-05-22 20:31:17 +03:00
huncode 5f3e543ea2 feat(task-023): bind workflow templates into demo flow 2026-05-22 19:38:29 +03:00
huncode baff9211e8 feat(task-022): add admin workflow builder ui 2026-05-22 04:05:47 +03:00
huncode cbd24d4a1c feat(task-021): add admin workflow template api 2026-05-22 03:45:02 +03:00
huncode d1ae5c4437 docs: define admin-managed workflow tasks 2026-05-22 03:22:38 +03:00
huncode 5ea0d5978a fix(task-020): support s3 assets in publishing dry-run and commit 2026-05-22 02:10:33 +03:00
huncode 7667819c46 feat(task-019): add deterministic end-to-end demo stack flow 2026-05-22 01:44:41 +03:00
huncode 95fec3ba26 feat(task-018): add observability retry cancel and audit trail 2026-05-22 01:23:07 +03:00
huncode 318ae790ef feat(task-017): implement git publishing dry-run and commit flow 2026-05-22 00:56:53 +03:00
huncode d3990b4019 feat: implement final approval gate workflow and checklist 2026-05-22 00:31:48 +03:00
huncode 6dcbe0e36c task015 implement seo and language review flow 2026-05-22 00:16:08 +03:00
huncode 8243f20b0e Implement task 014 assets and media library workflow 2026-05-21 23:55:15 +03:00
huncode c3914bf8be feat(task-013): add draft assembly API, immutable versioning, and editor preview 2026-05-21 23:33:15 +03:00
huncode 8ebb5ad623 feat(task-012): orchestrate parallel section production jobs 2026-05-21 23:10:34 +03:00
huncode 7e1b05067c Task 011 implement evidence matrix and claim gate 2026-05-21 22:52:41 +03:00
huncode 7d4a79acb5 Task 010 implement research manifest flow 2026-05-21 21:56:44 +03:00
huncode 5b27d1a3ca Task 009 implement plan generation review gate 2026-05-21 19:56:58 +03:00
huncode b7e2fa34b2 Task 008 implement boundary questions loop 2026-05-21 19:46:26 +03:00
huncode 8aa92ea728 Task 007 add agent job admin runner follow-up 2026-05-21 19:33:24 +03:00
huncode 4fd837349f Task 007 implement agent job queue and fake runner path 2026-05-21 19:28:37 +03:00
huncode ff1a2f78c0 Implement task 006 site config versioning and audit events 2026-05-21 19:13:19 +03:00
huncode cf84400118 feat(task-005): complete article intake dashboard detail flow 2026-05-21 18:54:11 +03:00
huncode 7972e9a95f Task 004: add demo auth and role checks 2026-05-21 18:31:40 +03:00
huncode 7a11d50aa7 Task 003: add postgres schema and seed data 2026-05-21 18:11:29 +03:00
huncode a0ae06adfa Task 002: add shared domain contracts 2026-05-21 17:45:00 +03:00
205 changed files with 43766 additions and 266 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
+7 -2
View File
@@ -8,6 +8,10 @@ The system accepts a short article description, asks boundary-setting questions,
v1 is an internal or single-tenant editorial tool. It is not a customer-facing multi-tenant SaaS product. v1 is an internal or single-tenant editorial tool. It is not a customer-facing multi-tenant SaaS product.
### Main principle
The workflow is a first-class Admin-managed product object, not a hard-coded backend status chain. The pipeline is gate-driven: each workflow stage defines its purpose, ordered parts, owner role, runner profile, required inputs, outputs, and acceptance criteria. Runtime execution cannot move to the next stage until the current stage is complete and every required human approval or validation gate has passed.
Chosen architecture: Chosen architecture:
```text ```text
@@ -50,6 +54,7 @@ Claude Code can be supported as an optional internal runner, but not as the main
14. Multi-site configuration 14. Multi-site configuration
15. Job history and audit trail 15. Job history and audit trail
16. Codex CLI-based agent execution 16. Codex CLI-based agent execution
17. Admin-managed workflow templates and editable workflow stages
``` ```
## Out of scope for v1 ## Out of scope for v1
@@ -76,13 +81,13 @@ The publishing step in v1 should create a **direct commit to the configured prod
## Admin ## Admin
Can configure target websites, pipeline parameters, agent runner profiles, prompt versions, publishing YAML, and site-specific transformation/upload scripts. Can configure target websites, workflow templates, workflow stages, pipeline parameters, agent runner profiles, prompt versions, publishing YAML, and site-specific transformation/upload scripts.
## Editor ## Editor
Can create briefs, answer boundary questions, run the pipeline, edit intermediate results, approve plans, edit drafts, approve final content, and create publish commits. Can create briefs, answer boundary questions, run the pipeline, edit intermediate results, approve plans, edit drafts, approve final content, and create publish commits.
Only Admins can edit upload scripts and pipeline configuration. Admins are fully trusted code operators because admin-defined transformation scripts run directly on the runner host inside checked-out site repositories. Only Admins can edit workflow configuration, upload scripts, and pipeline configuration. Admins are fully trusted code operators because admin-defined transformation scripts run directly on the runner host inside checked-out site repositories.
--- ---
+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
+15
View File
@@ -39,3 +39,18 @@ make smoke
The smoke test starts Docker Compose, checks frontend/backend/runner health The smoke test starts Docker Compose, checks frontend/backend/runner health
endpoints, and verifies that the backend can connect to Postgres, Redis, and endpoints, and verifies that the backend can connect to Postgres, Redis, and
S3-compatible object storage. S3-compatible object storage.
## End-to-End Demo
Run full deterministic demo stack:
```sh
docker compose up --build
```
The Compose profile is configured for demo mode and does not require external
credentials for Codex/search/WHOIS/cloud storage/GitHub.
Detailed steps (roles, happy path, failure/retry path, artifact inspection):
- [Demo Stack Guide](docs/demo-stack-guide.md)
+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
+6
View File
@@ -5,9 +5,15 @@ ENV PYTHONUNBUFFERED=1
WORKDIR /app WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends git nodejs npm \
&& rm -rf /var/lib/apt/lists/*
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
+356
View File
@@ -0,0 +1,356 @@
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,
AgentJobSummary,
AgentJobTestCodexRequest,
AgentJobType,
Role,
)
from src.application.observability import evaluate_retry_policy, project_job_for_view
from src.application.demo_runtime import (
build_demo_section_completion_payload,
should_auto_retry_section_job,
)
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:
jobs = [_project_admin_view(repository, job) for job in repository.agent_jobs.list()]
return AgentJobListResponse(jobs=jobs)
def get_agent_job(repository: object, job_id: UUID) -> AgentJobResponse:
job = repository.agent_jobs.get(job_id)
return AgentJobResponse(job=_project_admin_view(repository, job))
def retry_agent_job(
repository: object,
job_id: UUID,
*,
actor_user_id: UUID | None = None,
) -> AgentJobResponse:
parent = repository.agent_jobs.get(job_id)
retry_eligible, retry_block_reason = evaluate_retry_policy(repository, parent)
if not retry_eligible:
raise PermissionError(retry_block_reason or "Retry is blocked for this job.")
now = _now()
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],
payload=parent.payload,
queued_at=now,
)
_record_job_event(
repository,
job=retry,
event_type="AGENT_JOB_RETRIED",
actor_user_id=actor_user_id,
created_at=now,
payload={
"parent_job_id": str(parent.id),
"attempt": retry.attempt,
"job_type": retry.job_type.value,
},
)
if should_auto_retry_section_job(retry):
completion = build_demo_section_completion_payload(
payload=retry.payload,
failed=False,
)
return complete_agent_job(
repository,
job_id=retry.id,
workspace_path=f"/tmp/demo/{retry.id}",
stdout="deterministic demo section retry succeeded\n",
stderr="",
exit_code=0,
duration_ms=8,
output=completion,
)
return AgentJobResponse(job=_project_admin_view(repository, retry))
def cancel_agent_job(
repository: object,
job_id: UUID,
*,
actor_user_id: UUID | None = None,
) -> AgentJobResponse:
now = _now()
existing = repository.agent_jobs.get(job_id)
job = repository.agent_jobs.cancel(job_id=job_id, finished_at=now)
if existing.status != job.status and job.status == AgentJobStatus.CANCELLED:
_record_job_event(
repository,
job=job,
event_type="AGENT_JOB_CANCELLED",
actor_user_id=actor_user_id,
created_at=now,
payload={"job_type": job.job_type.value, "attempt": job.attempt},
)
return AgentJobResponse(job=_project_admin_view(repository, job))
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:
existing_job = repository.agent_jobs.get(job_id)
try:
validated_output = AgentJobOutput.model_validate(output)
except ValidationError as error:
finished_at = _now()
job = repository.agent_jobs.complete(
job_id=job_id,
status=AgentJobStatus.FAILED,
workspace_path=workspace_path,
output_files=[],
payload=existing_job.payload,
error_category=AgentJobErrorCategory.FAILED_SCHEMA_VALIDATION,
error_message=str(error),
stdout=stdout,
stderr=stderr,
exit_code=exit_code,
duration_ms=duration_ms,
finished_at=finished_at,
)
_record_completion_event(
repository,
previous=existing_job,
current=job,
created_at=finished_at,
)
return AgentJobResponse(job=_project_admin_view(repository, job))
merged_payload = _merge_payload(
base_payload=existing_job.payload,
new_payload=validated_output.payload,
)
status = _normalize_completion_status(
status=validated_output.status,
exit_code=exit_code,
error_category=validated_output.error_category,
)
error_category = _normalize_error_category(
initial=validated_output.error_category,
status=validated_output.status,
exit_code=exit_code,
)
error_message = validated_output.error_message
unsupported_claims = _extract_unsupported_claims(merged_payload)
if existing_job.job_type == AgentJobType.SECTION_SCAFFOLD and unsupported_claims:
status = AgentJobStatus.FAILED
error_category = AgentJobErrorCategory.UNSUPPORTED_CLAIMS_FOUND
if not error_message:
error_message = (
f"Unsupported claims introduced during scaffolding: {len(unsupported_claims)}"
)
finished_at = _now()
job = repository.agent_jobs.complete(
job_id=job_id,
status=status,
workspace_path=workspace_path,
output_files=[
file_ref.model_dump(mode="json") for file_ref in validated_output.output_files
],
payload=merged_payload,
error_category=error_category,
error_message=error_message,
stdout=stdout,
stderr=stderr,
exit_code=exit_code,
duration_ms=duration_ms,
finished_at=finished_at,
)
_record_completion_event(
repository,
previous=existing_job,
current=job,
created_at=finished_at,
)
return AgentJobResponse(job=_project_admin_view(repository, 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 (
status == AgentJobStatus.SUCCEEDED
and 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
def _merge_payload(
*,
base_payload: dict[str, Any],
new_payload: dict[str, Any],
) -> dict[str, Any]:
merged = dict(base_payload)
for key, value in new_payload.items():
merged[key] = value
return merged
def _extract_unsupported_claims(payload: dict[str, Any]) -> list[Any]:
claims = payload.get("unsupported_claims")
if isinstance(claims, list):
return claims
return []
def _project_admin_view(repository: object, job: AgentJobSummary) -> AgentJobSummary:
return project_job_for_view(repository, job, viewer_role=Role.ADMIN)
def _record_completion_event(
repository: object,
*,
previous: AgentJobSummary,
current: AgentJobSummary,
created_at: datetime,
) -> None:
if current.article_id is None:
return
if previous.status == current.status:
return
if current.status == AgentJobStatus.FAILED:
retry_eligible, _ = evaluate_retry_policy(repository, current)
_record_job_event(
repository,
job=current,
event_type="AGENT_JOB_FAILED",
actor_user_id=None,
created_at=created_at,
payload={
"job_type": current.job_type.value,
"attempt": current.attempt,
"error_category": (
current.error_category.value if current.error_category is not None else None
),
"error_message": current.error_message,
"retry_eligible": retry_eligible,
},
)
return
if current.status == AgentJobStatus.SUCCEEDED:
_record_job_event(
repository,
job=current,
event_type="AGENT_JOB_SUCCEEDED",
actor_user_id=None,
created_at=created_at,
payload={
"job_type": current.job_type.value,
"attempt": current.attempt,
},
)
def _record_job_event(
repository: object,
*,
job: AgentJobSummary,
event_type: str,
actor_user_id: UUID | None,
created_at: datetime,
payload: dict[str, Any],
) -> None:
if job.article_id is None:
return
repository.articles.create_workflow_event(
article_id=job.article_id,
event_type=event_type,
from_status=None,
to_status=None,
actor_user_id=actor_user_id,
payload={"job_id": str(job.id), **payload},
created_at=created_at,
)
+157
View File
@@ -0,0 +1,157 @@
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,
WorkflowTemplateSnapshot,
WorkflowTemplateStageSnapshot,
WorkflowTemplateStatus,
WorkflowTemplateSummary,
)
from src.application.observability import (
build_observability_timeline,
project_job_for_view,
)
def create_article(
repository: object,
request: ArticleCreateRequest,
current_user: CurrentUser,
) -> ArticleCreateResponse:
repository.target_sites.get_by_id(request.target_site_id)
workflow_snapshot = _resolve_workflow_template_snapshot(repository, request)
now = _now()
summary = repository.articles.create(
target_site_id=request.target_site_id,
workflow_template_id=workflow_snapshot.id if workflow_snapshot else None,
workflow_template_version=workflow_snapshot.version if workflow_snapshot else None,
workflow_template_snapshot=(
workflow_snapshot.model_dump(mode="json") if workflow_snapshot else None
),
status=ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
publishing_status=PublishingStatus.PUBLISH_NOT_STARTED,
brief_description=request.brief_description,
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,
current_user: CurrentUser,
) -> ArticleDetailResponse:
article = repository.articles.get(article_id)
target_site = repository.target_sites.get_by_id(article.target_site_id)
workflow_template_snapshot = repository.articles.get_workflow_template_snapshot(
article_id
)
workflow_events = repository.articles.list_workflow_events(article_id)
boundary_questions = repository.boundary_questions.list_for_article(article_id)
plans = repository.article_plans.list_for_article(article_id)
draft = repository.article_drafts.latest_for_article(article_id)
research_manifests = repository.research_manifests.list_for_article(article_id)
evidence = repository.evidence_items.list_for_article(article_id)
claims = repository.claims.list_for_article(article_id)
assets = repository.assets.list_for_article(article_id)
agent_jobs = repository.agent_jobs.list_for_article(article_id)
projected_jobs = [
project_job_for_view(
repository,
job,
viewer_role=current_user.role,
)
for job in agent_jobs
]
timeline = build_observability_timeline(workflow_events, projected_jobs)
publish_commit = repository.publish_commits.latest_for_article(article_id)
return ArticleDetailResponse(
article=article,
target_site=target_site,
workflow_template_snapshot=workflow_template_snapshot,
workflow_events=workflow_events,
boundary_questions=boundary_questions,
plan=plans[-1] if plans else None,
draft=draft,
evidence=evidence,
claims=claims,
assets=assets,
agent_jobs=projected_jobs,
timeline=timeline,
research_manifests=research_manifests,
publish_commit=publish_commit,
)
def _now() -> datetime:
return datetime.now(UTC)
def _resolve_workflow_template_snapshot(
repository: object,
request: ArticleCreateRequest,
) -> WorkflowTemplateSnapshot | None:
if request.workflow_template_id is None:
return None
try:
workflow = repository.workflow_templates.get(request.workflow_template_id)
except LookupError as error:
raise ValueError("Invalid workflow_template_id") from error
if workflow.status != WorkflowTemplateStatus.ACTIVE:
raise ValueError("Workflow template must be active")
return _workflow_template_snapshot(workflow)
def _workflow_template_snapshot(
workflow: WorkflowTemplateSummary,
) -> WorkflowTemplateSnapshot:
return WorkflowTemplateSnapshot(
id=workflow.id,
name=workflow.name,
slug=workflow.slug,
version=workflow.version,
stage_summary=[
WorkflowTemplateStageSnapshot(
id=stage.id,
stable_key=stage.stable_key,
display_name=stage.display_name,
position=stage.position,
owner_role=stage.owner_role,
runner_profile_key=stage.runner_profile_key,
requires_human_approval=stage.requires_human_approval,
)
for stage in workflow.stages
],
)
+361
View File
@@ -0,0 +1,361 @@
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
from uuid import UUID
from src.domain.contracts import (
AssetGenerateSpecsResponse,
AssetListResponse,
AssetResponse,
AssetStatus,
AssetSummary,
AssetType,
AssetUpdateRequest,
AssetUploadResponse,
CurrentUser,
PlanReviewStatus,
)
from src.infrastructure.object_storage import ObjectStorageClient
def generate_asset_specs(
repository: object,
*,
article_id: UUID,
current_user: CurrentUser,
) -> AssetGenerateSpecsResponse:
repository.articles.get(article_id)
plan = _approved_plan(repository, article_id=article_id)
if plan is None or not plan.sections:
raise PermissionError("Approved plan with sections is required before asset specs")
now = _now()
created_assets: list[AssetSummary] = []
for index, section in enumerate(plan.sections, start=1):
asset_type = _asset_type_for_section(index)
title = f"{section.heading} ({asset_type.value})"
asset = repository.assets.create(
article_id=article_id,
section_id=section.id,
asset_type=asset_type,
title=title,
prompt=(
"Create an editorial-ready visual for section "
f"'{section.heading}' with evidence-backed details."
),
object_key=None,
file_url=None,
alt_text=f"Visual for {section.heading}",
caption=f"Supporting visual for section: {section.heading}",
status=AssetStatus.GENERATED,
created_at=now,
updated_at=now,
)
revision_index = repository.assets.next_revision_index(
article_id=article_id,
asset_id=asset.id,
)
repository.assets.create_revision(
article_id=article_id,
asset_id=asset.id,
revision_index=revision_index,
action="SPEC_GENERATED",
actor_user_id=current_user.id,
payload={
"section_id": str(section.id),
"asset_type": asset.asset_type.value,
"title": asset.title,
},
created_at=now,
)
created_assets.append(
repository.assets.get(article_id=article_id, asset_id=asset.id)
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="ASSET_SPECS_GENERATED",
from_status=None,
to_status=None,
actor_user_id=current_user.id,
payload={
"count": len(created_assets),
"asset_ids": [str(asset.id) for asset in created_assets],
},
created_at=now,
)
return AssetGenerateSpecsResponse(assets=created_assets)
def list_assets(repository: object, *, article_id: UUID) -> AssetListResponse:
repository.articles.get(article_id)
return AssetListResponse(assets=repository.assets.list_for_article(article_id))
def update_asset(
repository: object,
*,
article_id: UUID,
asset_id: UUID,
request: AssetUpdateRequest,
current_user: CurrentUser,
) -> AssetResponse:
current = repository.assets.get(article_id=article_id, asset_id=asset_id)
next_section_id = request.section_id if request.section_id is not None else current.section_id
next_title = request.title if request.title is not None else current.title
next_prompt = request.prompt if request.prompt is not None else current.prompt
next_alt_text = request.alt_text if request.alt_text is not None else current.alt_text
next_caption = request.caption if request.caption is not None else current.caption
next_status = request.status if request.status is not None else current.status
changed_fields = _changed_fields(
current=current,
section_id=next_section_id,
title=next_title,
prompt=next_prompt,
alt_text=next_alt_text,
caption=next_caption,
status=next_status,
)
if not changed_fields:
return AssetResponse(asset=current)
now = _now()
repository.assets.update(
article_id=article_id,
asset_id=asset_id,
section_id=next_section_id,
title=next_title,
prompt=next_prompt,
object_key=current.object_key,
file_url=current.file_url,
alt_text=next_alt_text,
caption=next_caption,
status=next_status,
updated_at=now,
)
revision_index = repository.assets.next_revision_index(
article_id=article_id,
asset_id=asset_id,
)
repository.assets.create_revision(
article_id=article_id,
asset_id=asset_id,
revision_index=revision_index,
action="ASSET_UPDATED",
actor_user_id=current_user.id,
payload={
"changed_fields": changed_fields,
"status": next_status.value,
},
created_at=now,
)
if current.status != next_status:
repository.articles.create_workflow_event(
article_id=article_id,
event_type="ASSET_STATUS_UPDATED",
from_status=None,
to_status=None,
actor_user_id=current_user.id,
payload={
"asset_id": str(asset_id),
"from_status": current.status.value,
"to_status": next_status.value,
},
created_at=now,
)
refreshed = repository.assets.get(article_id=article_id, asset_id=asset_id)
return AssetResponse(asset=refreshed)
def approve_asset(
repository: object,
*,
article_id: UUID,
asset_id: UUID,
current_user: CurrentUser,
) -> AssetResponse:
current = repository.assets.get(article_id=article_id, asset_id=asset_id)
if current.status == AssetStatus.APPROVED:
return AssetResponse(asset=current)
now = _now()
repository.assets.update(
article_id=article_id,
asset_id=asset_id,
section_id=current.section_id,
title=current.title,
prompt=current.prompt,
object_key=current.object_key,
file_url=current.file_url,
alt_text=current.alt_text,
caption=current.caption,
status=AssetStatus.APPROVED,
updated_at=now,
)
approved = repository.assets.get(article_id=article_id, asset_id=asset_id)
revision_index = repository.assets.next_revision_index(
article_id=article_id,
asset_id=asset_id,
)
repository.assets.create_revision(
article_id=article_id,
asset_id=asset_id,
revision_index=revision_index,
action="ASSET_APPROVED",
actor_user_id=current_user.id,
payload={
"from_status": current.status.value,
"to_status": AssetStatus.APPROVED.value,
"object_key": approved.object_key,
},
created_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="ASSET_APPROVED",
from_status=None,
to_status=None,
actor_user_id=current_user.id,
payload={
"asset_id": str(asset_id),
"from_status": current.status.value,
"to_status": AssetStatus.APPROVED.value,
"object_key": approved.object_key,
"file_url": approved.file_url,
},
created_at=now,
)
refreshed = repository.assets.get(article_id=article_id, asset_id=asset_id)
return AssetResponse(asset=refreshed)
def upload_asset_file(
repository: object,
*,
article_id: UUID,
asset_id: UUID,
filename: str,
content_type: str | None,
content: bytes,
current_user: CurrentUser,
object_storage: ObjectStorageClient,
) -> AssetUploadResponse:
current = repository.assets.get(article_id=article_id, asset_id=asset_id)
now = _now()
revision_index = repository.assets.next_revision_index(
article_id=article_id,
asset_id=asset_id,
)
safe_filename = _safe_filename(filename)
object_key = f"articles/{article_id}/assets/{asset_id}/r{revision_index}/{safe_filename}"
file_url = object_storage.put_bytes(
object_key=object_key,
content=content,
content_type=content_type or "application/octet-stream",
)
had_previous_file = bool(current.object_key or current.file_url)
repository.assets.update(
article_id=article_id,
asset_id=asset_id,
section_id=current.section_id,
title=current.title,
prompt=current.prompt,
object_key=object_key,
file_url=file_url,
alt_text=current.alt_text,
caption=current.caption,
status=AssetStatus.PENDING,
updated_at=now,
)
action = "ASSET_FILE_REPLACED" if had_previous_file else "ASSET_FILE_UPLOADED"
repository.assets.create_revision(
article_id=article_id,
asset_id=asset_id,
revision_index=revision_index,
action=action,
actor_user_id=current_user.id,
payload={
"previous_object_key": current.object_key,
"previous_file_url": current.file_url,
"next_object_key": object_key,
"next_file_url": file_url,
"content_type": content_type or "application/octet-stream",
"filename": safe_filename,
},
created_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type=action,
from_status=None,
to_status=None,
actor_user_id=current_user.id,
payload={
"asset_id": str(asset_id),
"previous_object_key": current.object_key,
"next_object_key": object_key,
},
created_at=now,
)
refreshed = repository.assets.get(article_id=article_id, asset_id=asset_id)
return AssetUploadResponse(asset=refreshed)
def _approved_plan(repository: object, *, article_id: UUID) -> object | None:
plans = repository.article_plans.list_for_article(article_id)
approved_plans = [plan for plan in plans if plan.status == PlanReviewStatus.APPROVED]
if not approved_plans:
return None
approved_plans.sort(key=lambda plan: plan.version)
return approved_plans[-1]
def _asset_type_for_section(index: int) -> AssetType:
ordered_types = [
AssetType.HERO_IMAGE,
AssetType.ARCHITECTURE_DIAGRAM,
AssetType.DIAGRAM,
AssetType.TABLE,
AssetType.FLOWCHART,
AssetType.COMPARISON_MATRIX,
AssetType.INLINE_IMAGE,
]
return ordered_types[(index - 1) % len(ordered_types)]
def _changed_fields(
*,
current: AssetSummary,
section_id: UUID | None,
title: str,
prompt: str | None,
alt_text: str | None,
caption: str | None,
status: AssetStatus,
) -> list[str]:
changed: list[str] = []
if current.section_id != section_id:
changed.append("section_id")
if current.title != title:
changed.append("title")
if current.prompt != prompt:
changed.append("prompt")
if current.alt_text != alt_text:
changed.append("alt_text")
if current.caption != caption:
changed.append("caption")
if current.status != status:
changed.append("status")
return changed
def _safe_filename(value: str) -> str:
filename = Path(value).name.strip()
if not filename:
return "asset.bin"
return filename.replace("/", "_")
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,266 @@
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"}],
payload={},
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)
@@ -0,0 +1,131 @@
from __future__ import annotations
import os
import re
import subprocess
import tempfile
from pathlib import Path
from typing import Any
from src.domain.contracts import AgentJobSummary
_DEMO_MODE_ENV = "PIPELINE_DEMO_MODE"
_DEMO_REPOSITORY_ENV = "PIPELINE_DEMO_PUBLISH_REPO_PATH"
_DEFAULT_DEMO_REPOSITORY_PATH = "/tmp/pipeline-demo-site.git"
def is_demo_mode() -> bool:
value = os.environ.get(_DEMO_MODE_ENV, "").strip().lower()
return value in {"1", "true", "yes", "on"}
def resolve_demo_repository_path() -> Path:
configured = os.environ.get(_DEMO_REPOSITORY_ENV, _DEFAULT_DEMO_REPOSITORY_PATH)
return Path(configured).expanduser().resolve()
def ensure_demo_bare_repository(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if not path.exists():
_run_git(["init", "--bare", str(path)])
if _has_main_branch(path):
return
with tempfile.TemporaryDirectory(prefix="demo-site-seed-") as temp_dir:
worktree = Path(temp_dir) / "seed"
worktree.mkdir(parents=True, exist_ok=True)
_run_git(["init"], cwd=worktree)
_run_git(["config", "user.name", "Pipeline Demo Bot"], cwd=worktree)
_run_git(["config", "user.email", "pipeline-demo-bot@example.com"], cwd=worktree)
(worktree / "README.md").write_text("# Pipeline Demo Site\n", encoding="utf-8")
_run_git(["add", "README.md"], cwd=worktree)
_run_git(["commit", "-m", "seed demo repository"], cwd=worktree)
_run_git(["branch", "-M", "main"], cwd=worktree)
_run_git(["remote", "add", "origin", str(path)], cwd=worktree)
_run_git(["push", "origin", "main"], cwd=worktree)
def should_auto_run_section_jobs() -> bool:
return is_demo_mode()
def should_auto_retry_section_job(job: AgentJobSummary) -> bool:
if not is_demo_mode():
return False
if job.payload.get("demo_fail_once") is True and job.attempt > 1:
return True
return False
def build_demo_section_completion_payload(
*,
payload: dict[str, Any],
failed: bool,
) -> dict[str, Any]:
heading = _string_value(payload.get("heading")) or "Section"
section_id = _string_value(payload.get("section_id")) or "section"
draft_markdown = (
f"## {heading}\n\n"
f"This demo section is generated deterministically for `{section_id}`.\n\n"
"Read the related architecture guide: [internal reference](/guides/pipeline-demo).\n"
)
if failed:
return {
"status": "FAILED",
"error_category": "CLI_EXIT_CODE_FAILURE",
"error_message": "Deterministic demo failure for retry walkthrough.",
"payload": {
"section_id": section_id,
"heading": heading,
"artifact_label": heading,
"last_successful_step": "outline generated",
"unsupported_claims": [],
"draft_markdown": "",
},
}
return {
"status": "SUCCEEDED",
"output_files": [{"path": f"outputs/{section_id}.md"}],
"payload": {
"section_id": section_id,
"heading": heading,
"artifact_label": heading,
"unsupported_claims": [],
"last_successful_step": "section scaffold complete",
"suggested_visuals": [f"{heading} diagram"],
"draft_markdown": draft_markdown,
},
}
def slugify(value: str) -> str:
normalized = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-")
return normalized or "article"
def _has_main_branch(path: Path) -> bool:
result = subprocess.run(
["git", "--git-dir", str(path), "rev-parse", "--verify", "refs/heads/main"],
capture_output=True,
text=True,
check=False,
)
return result.returncode == 0
def _run_git(command: list[str], *, cwd: Path | None = None) -> None:
subprocess.run(
["git", *command] if command[0] != "git" else command,
cwd=str(cwd) if cwd is not None else None,
capture_output=True,
text=True,
check=True,
)
def _string_value(value: Any) -> str:
if isinstance(value, str):
return value.strip()
return ""
+485
View File
@@ -0,0 +1,485 @@
from __future__ import annotations
import re
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from src.domain.contracts import (
AgentJobStatus,
AgentJobSummary,
AgentJobType,
AssetStatus,
ArticleWorkflowStatus,
DraftAssembleResponse,
DraftFaqItem,
DraftListResponse,
DraftResponse,
DraftUpdateRequest,
PlanReviewStatus,
)
def assemble_draft(repository: object, *, article_id: UUID) -> DraftAssembleResponse:
article = repository.articles.get(article_id)
approved_plan = _approved_plan(repository, article_id=article_id)
if approved_plan is None or not approved_plan.sections:
raise PermissionError("Approved plan with sections is required before draft assembly")
section_jobs = repository.agent_jobs.list_for_article(article_id)
successful_jobs_by_section = _successful_jobs_by_section(section_jobs)
missing_section_headings = [
section.heading
for section in approved_plan.sections
if str(section.id) not in successful_jobs_by_section
]
if missing_section_headings:
raise ValueError(
"Missing successful section scaffolds for sections: "
+ ", ".join(missing_section_headings)
)
title = (
approved_plan.recommended_title
or article.working_title
or article.primary_keyword
or "Untitled draft"
)
meta_title = title
meta_description = _trim_description(article.brief_description or title)
section_blocks: list[str] = []
visual_placeholders: list[str] = []
evidence_references: list[str] = []
unsupported_warnings = _collect_job_warnings(section_jobs)
approved_assets_by_section = _approved_assets_by_section(
repository,
article_id=article_id,
)
for section in approved_plan.sections:
section_job = successful_jobs_by_section[str(section.id)]
payload = section_job.payload
section_blocks.append(_section_markdown(section.heading, payload))
visual_placeholders.extend(
_visual_placeholders_for_section(
section_id=section.id,
heading=section.heading,
payload=payload,
approved_assets=approved_assets_by_section.get(str(section.id), []),
)
)
evidence_references.extend(_string_list(payload.get("used_evidence_ids")))
deduped_evidence_references = _dedupe_strings(evidence_references)
deduped_unsupported_warnings = _dedupe_strings(unsupported_warnings)
deduped_visual_placeholders = _dedupe_strings(visual_placeholders)
faq_items = _build_faq_items(
claims_to_prove=approved_plan.claims_to_prove,
keyword=article.primary_keyword,
)
draft_body = _assemble_body_markdown(
title=title,
meta_title=meta_title,
meta_description=meta_description,
section_blocks=section_blocks,
evidence_references=deduped_evidence_references,
visual_placeholders=deduped_visual_placeholders,
faq_items=faq_items,
unsupported_warnings=deduped_unsupported_warnings,
)
now = _now()
next_version = repository.article_drafts.latest_version(article_id) + 1
draft = repository.article_drafts.create_version(
article_id=article_id,
version=next_version,
title=title,
slug=_slugify(title),
meta_title=meta_title,
meta_description=meta_description,
body_object_key=f"articles/{article_id}/drafts/v{next_version}.md",
body_markdown=draft_body,
faq_items=[item.model_dump(mode="json") for item in faq_items],
visual_placeholders=deduped_visual_placeholders,
evidence_references=[UUID(item_id) for item_id in deduped_evidence_references],
unsupported_claim_warnings=deduped_unsupported_warnings,
based_on_draft_id=None,
status=ArticleWorkflowStatus.DRAFT_ASSEMBLED,
created_at=now,
updated_at=now,
)
repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.DRAFT_ASSEMBLED,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="DRAFT_ASSEMBLED",
from_status=article.status,
to_status=ArticleWorkflowStatus.DRAFT_ASSEMBLED,
actor_user_id=None,
payload={
"draft_id": str(draft.id),
"version": draft.version,
"section_count": len(approved_plan.sections),
},
created_at=now,
)
return DraftAssembleResponse(draft=draft)
def list_drafts(repository: object, *, article_id: UUID) -> DraftListResponse:
repository.articles.get(article_id)
return DraftListResponse(drafts=repository.article_drafts.list_for_article(article_id))
def get_draft(repository: object, *, article_id: UUID, draft_id: UUID) -> DraftResponse:
repository.articles.get(article_id)
draft = repository.article_drafts.get(article_id=article_id, draft_id=draft_id)
return DraftResponse(draft=draft)
def update_draft(
repository: object,
*,
article_id: UUID,
draft_id: UUID,
request: DraftUpdateRequest,
) -> DraftResponse:
repository.articles.get(article_id)
base = repository.article_drafts.get(article_id=article_id, draft_id=draft_id)
if _request_is_empty(request):
raise ValueError("Draft update payload is empty")
next_version = repository.article_drafts.latest_version(article_id) + 1
now = _now()
title = request.title if request.title is not None else base.title
draft = repository.article_drafts.create_version(
article_id=article_id,
version=next_version,
title=title,
slug=_slugify(title),
meta_title=(
request.meta_title if request.meta_title is not None else base.meta_title
),
meta_description=(
request.meta_description
if request.meta_description is not None
else base.meta_description
),
body_object_key=f"articles/{article_id}/drafts/v{next_version}.md",
body_markdown=(
request.body_markdown
if request.body_markdown is not None
else base.body_markdown
),
faq_items=[
item.model_dump(mode="json")
for item in (
request.faq_items
if request.faq_items is not None
else base.faq_items
)
],
visual_placeholders=(
request.visual_placeholders
if request.visual_placeholders is not None
else base.visual_placeholders
),
evidence_references=(
request.evidence_references
if request.evidence_references is not None
else base.evidence_references
),
unsupported_claim_warnings=(
request.unsupported_claim_warnings
if request.unsupported_claim_warnings is not None
else base.unsupported_claim_warnings
),
based_on_draft_id=base.id,
status=base.status,
created_at=now,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="DRAFT_VERSION_CREATED",
from_status=ArticleWorkflowStatus.DRAFT_ASSEMBLED,
to_status=ArticleWorkflowStatus.DRAFT_ASSEMBLED,
actor_user_id=None,
payload={
"draft_id": str(draft.id),
"version": draft.version,
"based_on_draft_id": str(base.id),
},
created_at=now,
)
return DraftResponse(draft=draft)
def _approved_plan(repository: object, *, article_id: UUID) -> object | None:
plans = repository.article_plans.list_for_article(article_id)
approved_plans = [
plan for plan in plans if plan.status == PlanReviewStatus.APPROVED
]
if not approved_plans:
return None
approved_plans.sort(key=lambda plan: plan.version)
return approved_plans[-1]
def _successful_jobs_by_section(
jobs: list[AgentJobSummary],
) -> dict[str, AgentJobSummary]:
sections: dict[str, AgentJobSummary] = {}
for job in jobs:
if job.job_type != AgentJobType.SECTION_SCAFFOLD:
continue
if job.status != AgentJobStatus.SUCCEEDED:
continue
section_id = _string_value(job.payload.get("section_id"))
if not section_id:
continue
existing = sections.get(section_id)
if existing is None or _job_sort_key(job) > _job_sort_key(existing):
sections[section_id] = job
return sections
def _collect_job_warnings(jobs: list[AgentJobSummary]) -> list[str]:
warnings: list[str] = []
for job in jobs:
if job.job_type != AgentJobType.SECTION_SCAFFOLD:
continue
heading = _string_value(job.payload.get("heading")) or _string_value(
job.payload.get("artifact_label")
)
warnings.extend(
_unsupported_claim_warnings(
heading=heading or "section",
raw_claims=job.payload.get("unsupported_claims"),
)
)
return warnings
def _job_sort_key(job: AgentJobSummary) -> tuple[int, str]:
queued_at = job.queued_at.isoformat() if job.queued_at else ""
return (job.attempt, queued_at)
def _section_markdown(heading: str, payload: dict[str, Any]) -> str:
section_markdown = _string_value(payload.get("draft_markdown")).strip()
if not section_markdown:
return f"## {heading}\n\nDraft content is missing for this section."
if section_markdown.lstrip().startswith("#"):
return section_markdown
return f"## {heading}\n\n{section_markdown}"
def _visual_placeholders_for_section(
*,
section_id: UUID,
heading: str,
payload: dict[str, Any],
approved_assets: list[object],
) -> list[str]:
if approved_assets:
return [_approved_asset_markdown(asset) for asset in approved_assets]
suggested_visuals = payload.get("suggested_visuals")
placeholders: list[str] = []
if isinstance(suggested_visuals, list):
for index, visual in enumerate(suggested_visuals, start=1):
label = _visual_label(visual, heading=heading, fallback_index=index)
placeholders.append(
f"![{label}](placeholder://{section_id}/{index})"
)
if placeholders:
return placeholders
return [f"![Visual placeholder: {heading}](placeholder://{section_id}/1)"]
def _approved_assets_by_section(repository: object, *, article_id: UUID) -> dict[str, list[object]]:
assets = repository.assets.list_for_article(article_id)
by_section: dict[str, list[object]] = {}
for asset in assets:
if asset.status != AssetStatus.APPROVED:
continue
if not asset.section_id:
continue
if not asset.file_url:
continue
by_section.setdefault(str(asset.section_id), []).append(asset)
return by_section
def _approved_asset_markdown(asset: object) -> str:
alt = _string_value(getattr(asset, "alt_text", None)) or _string_value(
getattr(asset, "title", None)
)
src = _string_value(getattr(asset, "file_url", None))
if not src:
return ""
return f"![{alt}]({src})"
def _visual_label(value: Any, *, heading: str, fallback_index: int) -> str:
if isinstance(value, str) and value.strip():
return value.strip()
if isinstance(value, dict):
for key in ("title", "caption", "prompt", "label"):
key_value = value.get(key)
if isinstance(key_value, str) and key_value.strip():
return key_value.strip()
return f"{heading} visual {fallback_index}"
def _unsupported_claim_warnings(*, heading: str, raw_claims: Any) -> list[str]:
if not isinstance(raw_claims, list):
return []
warnings: list[str] = []
for claim in raw_claims:
if isinstance(claim, str) and claim.strip():
warnings.append(f"{heading}: {claim.strip()}")
continue
if isinstance(claim, dict):
claim_text = claim.get("claim_text")
if isinstance(claim_text, str) and claim_text.strip():
warnings.append(f"{heading}: {claim_text.strip()}")
return warnings
def _build_faq_items(
*,
claims_to_prove: list[str],
keyword: str | None,
) -> list[DraftFaqItem]:
faq_items: list[DraftFaqItem] = []
for claim in claims_to_prove[:3]:
text = claim.strip()
if not text:
continue
faq_items.append(
DraftFaqItem(
question=f"What evidence supports: {text}?",
answer="Evidence references are listed in the section and summary blocks.",
)
)
if faq_items:
return faq_items
base_keyword = (keyword or "this topic").strip()
return [
DraftFaqItem(
question=f"What should readers know about {base_keyword}?",
answer="The draft sections provide context, implementation details, and references.",
)
]
def _assemble_body_markdown(
*,
title: str,
meta_title: str,
meta_description: str,
section_blocks: list[str],
evidence_references: list[str],
visual_placeholders: list[str],
faq_items: list[DraftFaqItem],
unsupported_warnings: list[str],
) -> str:
lines = [
f"# {title}",
"",
f"> Meta title: {meta_title}",
f"> Meta description: {meta_description}",
"",
]
lines.extend("\n\n".join(section_blocks).splitlines())
lines.extend(["", "## Evidence References", ""])
if evidence_references:
for evidence_id in evidence_references:
lines.append(f"- `{evidence_id}`")
else:
lines.append("- No evidence references recorded.")
lines.extend(["", "## Visual Placeholders", ""])
for placeholder in visual_placeholders:
lines.append(placeholder)
lines.extend(["", "## FAQ", ""])
for item in faq_items:
lines.append(f"### {item.question}")
lines.append("")
lines.append(item.answer)
lines.append("")
if unsupported_warnings:
lines.extend(["## Unsupported Claim Warnings", ""])
for warning in unsupported_warnings:
lines.append(f"- {warning}")
lines.append("")
return "\n".join(lines).strip() + "\n"
def _trim_description(value: str) -> str:
normalized = " ".join(value.split())
if len(normalized) <= 155:
return normalized
return normalized[:152].rstrip() + "..."
def _slugify(value: str) -> str:
normalized = value.strip().lower()
normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
normalized = normalized.strip("-")
if normalized:
return normalized
return "draft"
def _string_list(value: Any) -> list[str]:
if not isinstance(value, list):
return []
return [item for item in value if isinstance(item, str) and item]
def _string_value(value: Any) -> str:
if isinstance(value, str):
return value
return ""
def _dedupe_strings(values: list[str]) -> list[str]:
deduped: list[str] = []
seen: set[str] = set()
for value in values:
if value in seen:
continue
seen.add(value)
deduped.append(value)
return deduped
def _request_is_empty(request: DraftUpdateRequest) -> bool:
return all(
value is None
for value in (
request.title,
request.meta_title,
request.meta_description,
request.body_markdown,
request.faq_items,
request.visual_placeholders,
request.evidence_references,
request.unsupported_claim_warnings,
)
)
def _now() -> datetime:
return datetime.now(UTC)
+352
View File
@@ -0,0 +1,352 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import UUID
from src.application.agent_jobs import complete_agent_job
from src.application.demo_runtime import (
build_demo_section_completion_payload,
should_auto_run_section_jobs,
)
from src.domain.contracts import (
AgentJobListResponse,
AgentJobStatus,
AgentJobType,
ArticleCreateResponse,
ArticleWorkflowStatus,
ClaimRiskLevel,
ClaimSupportStatus,
EvidenceMatrixResponse,
EvidenceResponse,
EvidenceCreateRequest,
EvidenceUpdateRequest,
PlanReviewStatus,
)
def get_evidence_matrix(repository: object, *, article_id: UUID) -> EvidenceMatrixResponse:
article = repository.articles.get(article_id)
manifests = repository.research_manifests.list_for_article(article_id)
if not manifests:
return EvidenceMatrixResponse(article=article)
if not repository.claims.list_for_article(article_id):
_build_matrix(repository, article=article, manifest=manifests[-1])
article = repository.articles.get(article_id)
evidence = repository.evidence_items.list_for_article(article_id)
claims = repository.claims.list_for_article(article_id)
reasons = _insufficient_reasons(claims)
if reasons and article.status != ArticleWorkflowStatus.PLAN_REVISION_REQUIRED:
from_status = article.status
now = _now()
article = 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="INSUFFICIENT_EVIDENCE_FOUND",
from_status=from_status,
to_status=ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
actor_user_id=None,
payload={"reasons": reasons},
created_at=now,
)
if not reasons and article.status == ArticleWorkflowStatus.RESEARCH_RUNNING:
now = _now()
article = repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="EVIDENCE_MATRIX_READY",
from_status=ArticleWorkflowStatus.RESEARCH_RUNNING,
to_status=ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
actor_user_id=None,
payload={
"evidence_count": len(evidence),
"claim_count": len(claims),
},
created_at=now,
)
return EvidenceMatrixResponse(
article=article,
evidence=evidence,
claims=claims,
insufficient_evidence_reasons=reasons,
)
def update_evidence(
repository: object,
*,
article_id: UUID,
evidence_id: UUID,
request: EvidenceUpdateRequest,
) -> EvidenceResponse:
repository.articles.get(article_id)
evidence = repository.evidence_items.update_review_status(
article_id=article_id,
evidence_id=evidence_id,
review_status=request.review_status or "PENDING",
)
return EvidenceResponse(evidence=evidence)
def add_evidence(
repository: object,
*,
article_id: UUID,
request: EvidenceCreateRequest,
) -> EvidenceResponse:
repository.articles.get(article_id)
now = _now()
evidence = repository.evidence_items.create(
article_id=article_id,
source_title=request.source_title,
source_url=request.source_url,
source_type=request.source_type,
source_quality_score=request.source_quality_score,
summary=request.summary,
supports_claims=[],
artifact_manifest_id=_resolve_manifest_id(repository, article_id=article_id),
retrieved_at=now,
review_status="PENDING",
)
if request.claim_text:
repository.claims.create(
article_id=article_id,
section_id=request.section_id,
claim_text=request.claim_text,
support_status=request.support_status,
risk_level=request.risk_level,
evidence_item_ids=[evidence.id],
created_at=now,
)
return EvidenceResponse(evidence=evidence)
def remove_evidence(
repository: object,
*,
article_id: UUID,
evidence_id: UUID,
) -> None:
repository.articles.get(article_id)
evidence = repository.evidence_items.get(evidence_id)
if evidence.article_id != article_id:
raise LookupError(f"Evidence not found: {evidence_id}")
for claim in repository.claims.list_for_article(article_id):
if evidence_id not in claim.evidence_item_ids:
continue
remaining_ids = [item_id for item_id in claim.evidence_item_ids if item_id != evidence_id]
repository.claims.update_evidence_links(
claim_id=claim.id,
evidence_item_ids=remaining_ids,
support_status=(
claim.support_status
if remaining_ids
else ClaimSupportStatus.UNSUPPORTED
),
)
repository.evidence_items.delete(article_id=article_id, evidence_id=evidence_id)
def start_draft(repository: object, *, article_id: UUID) -> AgentJobListResponse:
article = repository.articles.get(article_id)
if article.status != ArticleWorkflowStatus.EVIDENCE_MATRIX_READY:
raise PermissionError("Evidence matrix must be ready before draft production")
matrix = get_evidence_matrix(repository, article_id=article_id)
if matrix.insufficient_evidence_reasons:
raise PermissionError("Acceptable evidence is required before draft production")
if matrix.article.status != ArticleWorkflowStatus.EVIDENCE_MATRIX_READY:
raise PermissionError("Evidence matrix must be ready before draft production")
approved_plan = _approved_plan(repository, article_id=article_id)
if approved_plan is None or not approved_plan.sections:
raise PermissionError("Approved plan with sections is required before draft production")
claim_evidence_by_section = _claim_evidence_ids_by_section(matrix.claims)
now = _now()
repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="PARALLEL_PRODUCTION_STARTED",
from_status=ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
to_status=ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING,
actor_user_id=None,
payload={"section_count": len(approved_plan.sections)},
created_at=now,
)
jobs = []
for section in approved_plan.sections:
used_evidence_ids = claim_evidence_by_section.get(str(section.id), [])
demo_fail_once = should_auto_run_section_jobs() and len(jobs) == 0
jobs.append(
repository.agent_jobs.create(
article_id=article_id,
parent_job_id=None,
attempt=1,
job_type=AgentJobType.SECTION_SCAFFOLD,
agent_profile="fake-section-scaffold",
status=AgentJobStatus.QUEUED,
input_files=[
{"path": "inputs/evidence-matrix.json", "content_hash": None},
{"path": "inputs/approved-plan.json", "content_hash": None},
{"path": f"inputs/sections/{section.id}.json", "content_hash": None},
],
payload={
"artifact_key": f"section:{section.id}",
"artifact_label": section.heading,
"artifact_type": "section_scaffold",
"section_id": str(section.id),
"heading": section.heading,
"used_evidence_ids": used_evidence_ids,
"unsupported_claims": [],
"suggested_visuals": [],
"draft_markdown": "",
"demo_fail_once": demo_fail_once,
},
queued_at=now,
)
)
if should_auto_run_section_jobs():
_auto_complete_demo_section_jobs(repository, jobs)
jobs = repository.agent_jobs.list_for_article(article_id)
return AgentJobListResponse(jobs=jobs)
def approve_final(repository: object, *, article_id: UUID) -> ArticleCreateResponse:
matrix = get_evidence_matrix(repository, article_id=article_id)
high_risk_unsupported = [
claim
for claim in matrix.claims
if claim.support_status == ClaimSupportStatus.UNSUPPORTED
and claim.risk_level == ClaimRiskLevel.HIGH
]
if high_risk_unsupported:
raise PermissionError("High-risk unsupported claims must be resolved")
return ArticleCreateResponse(article=matrix.article)
def _build_matrix(repository: object, *, article: object, manifest: object) -> None:
now = _now()
insufficient = "insufficient" in (
f"{article.brief_description} {article.primary_keyword or ''}".lower()
)
plan = repository.article_plans.list_for_article(article.id)[-1]
first_section_id = plan.sections[0].id if plan.sections else None
if insufficient:
repository.claims.create(
article_id=article.id,
section_id=first_section_id,
claim_text="High-risk factual claim without acceptable source support.",
support_status=ClaimSupportStatus.UNSUPPORTED,
risk_level=ClaimRiskLevel.HIGH,
evidence_item_ids=[],
created_at=now,
)
for artifact in manifest.artifacts:
metadata = artifact.metadata
evidence = repository.evidence_items.create(
article_id=article.id,
source_title=str(metadata.get("source_title", "Research source")),
source_url=artifact.source_url,
source_type=str(metadata.get("source_type", artifact.artifact_type)),
source_quality_score=0.86,
summary=str(metadata.get("summary", "Research source summary")),
supports_claims=[],
artifact_manifest_id=manifest.id,
retrieved_at=now,
)
repository.claims.create(
article_id=article.id,
section_id=first_section_id,
claim_text=str(metadata.get("summary", "Supported factual claim")),
support_status=ClaimSupportStatus.SUPPORTED,
risk_level=ClaimRiskLevel.LOW,
evidence_item_ids=[evidence.id],
created_at=now,
)
def _resolve_manifest_id(repository: object, *, article_id: UUID) -> UUID:
manifests = repository.research_manifests.list_for_article(article_id)
if manifests:
return manifests[-1].id
raise ValueError("Research manifest is required before adding manual evidence")
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 _claim_evidence_ids_by_section(claims: list[object]) -> dict[str, list[str]]:
mapping: dict[str, list[str]] = {}
for claim in claims:
if claim.section_id is None:
continue
section_id = str(claim.section_id)
bucket = mapping.setdefault(section_id, [])
for evidence_id in claim.evidence_item_ids:
encoded = str(evidence_id)
if encoded not in bucket:
bucket.append(encoded)
return mapping
def _insufficient_reasons(claims: list[object]) -> list[str]:
reasons: list[str] = []
for claim in claims:
if claim.support_status == ClaimSupportStatus.UNSUPPORTED:
reasons.append(f"Unsupported claim: {claim.claim_text}")
return reasons
def _now() -> datetime:
return datetime.now(UTC)
def _auto_complete_demo_section_jobs(
repository: object,
jobs: list[object],
) -> None:
for index, job in enumerate(jobs):
failed = index == 0
output = build_demo_section_completion_payload(
payload=job.payload,
failed=failed,
)
complete_agent_job(
repository,
job_id=job.id,
workspace_path=f"/tmp/demo/{job.id}",
stdout=(
"deterministic demo failure; retry to continue\n"
if failed
else "deterministic demo section scaffold succeeded\n"
),
stderr="" if not failed else "exit code 1 simulated\n",
exit_code=1 if failed else 0,
duration_ms=12,
output=output,
)
@@ -0,0 +1,378 @@
from __future__ import annotations
import re
from datetime import UTC, datetime
from uuid import UUID
from src.domain.contracts import (
AgentJobType,
AssetStatus,
AssetType,
ArticleWorkflowStatus,
ClaimRiskLevel,
ClaimSupportStatus,
ContentReviewKind,
FinalApprovalChecklistItemSummary,
FinalApprovalChecklistResponse,
FinalApprovalRequest,
FinalApprovalResponse,
FinalRevisionRequest,
FinalRevisionRequestResponse,
PlanReviewStatus,
)
def get_final_approval_checklist(
repository: object,
*,
article_id: UUID,
request: FinalApprovalRequest | None = None,
) -> FinalApprovalChecklistResponse:
repository.articles.get(article_id)
latest_draft = repository.article_drafts.latest_for_article(article_id)
approved_plan = _approved_plan(repository, article_id=article_id)
evidence = repository.evidence_items.list_for_article(article_id)
assets = repository.assets.list_for_article(article_id)
unresolved = repository.content_reviews.list_unresolved_for_article(article_id=article_id)
high_risk_unsupported = _high_risk_unsupported_claims(
repository,
article_id=article_id,
draft=latest_draft,
)
settings = request.publishing_settings if request is not None else None
requested_draft_version = request.draft_version if request is not None else None
checklist_items: list[FinalApprovalChecklistItemSummary] = []
blockers: list[str] = []
completed_items: list[str] = []
def add_item(key: str, title: str, completed: bool, blocker: str | None = None) -> None:
checklist_items.append(
FinalApprovalChecklistItemSummary(
key=key,
title=title,
completed=completed,
blocker=blocker,
)
)
if completed:
completed_items.append(title)
elif blocker:
blockers.append(blocker)
has_draft = latest_draft is not None
add_item(
key="exact_draft_version",
title="Exact draft version selected",
completed=has_draft and requested_draft_version == latest_draft.version,
blocker=(
None
if has_draft and requested_draft_version == latest_draft.version
else (
"Draft version is required for final approval."
if not has_draft
else f"Final approval requires latest draft version {latest_draft.version}."
)
),
)
add_item(
key="plan_followed",
title="Plan followed",
completed=has_draft and _draft_contains_approved_plan(approved_plan, latest_draft.body_markdown),
blocker="Approved plan sections are not fully reflected in the assembled draft.",
)
add_item(
key="evidence_reviewed",
title="Evidence reviewed",
completed=bool(evidence) and all(item.review_status != "PENDING" for item in evidence),
blocker="Evidence review is incomplete (there are pending evidence items).",
)
add_item(
key="no_high_risk_unsupported_claims",
title="No high-risk unsupported claims remain",
completed=not high_risk_unsupported,
blocker=(
None
if not high_risk_unsupported
else (
"High-risk unsupported claims must be resolved: "
+ "; ".join(high_risk_unsupported[:3])
)
),
)
add_item(
key="seo_metadata_approved",
title="SEO metadata approved",
completed=has_draft and _has_seo_metadata(latest_draft) and not _has_unresolved_meta_issues(unresolved),
blocker="SEO metadata is incomplete or has unresolved review issues.",
)
required_assets = [asset for asset in assets if asset.status != AssetStatus.REJECTED]
unapproved_required_assets = [
asset
for asset in required_assets
if asset.status != AssetStatus.APPROVED or not asset.file_url
]
add_item(
key="images_assets_approved",
title="Images/assets approved",
completed=bool(required_assets) and not unapproved_required_assets,
blocker=(
"Required assets are missing."
if not required_assets
else f"{len(unapproved_required_assets)} required assets are not approved."
),
)
required_table_diagrams = [
asset
for asset in required_assets
if asset.asset_type in {
AssetType.DIAGRAM,
AssetType.TABLE,
AssetType.FLOWCHART,
AssetType.COMPARISON_MATRIX,
AssetType.ARCHITECTURE_DIAGRAM,
}
]
unapproved_table_diagrams = [
asset
for asset in required_table_diagrams
if asset.status != AssetStatus.APPROVED or not asset.file_url
]
add_item(
key="tables_and_diagrams_approved",
title="Tables and diagrams approved",
completed=bool(required_table_diagrams) and not unapproved_table_diagrams,
blocker=(
"Required table/diagram assets are missing."
if not required_table_diagrams
else f"{len(unapproved_table_diagrams)} table/diagram assets are not approved."
),
)
has_internal_links = has_draft and _has_internal_links(latest_draft.body_markdown)
add_item(
key="internal_links_approved",
title="Internal links approved",
completed=bool(has_internal_links) and not _has_unresolved_internal_link_issue(unresolved),
blocker="Draft must include approved internal links.",
)
frontmatter = settings.frontmatter if settings is not None else None
add_item(
key="frontmatter_fields_selected",
title="Frontmatter fields selected",
completed=isinstance(frontmatter, dict) and bool(frontmatter),
blocker="Required publishing settings are missing: frontmatter fields.",
)
content_path = settings.content_path if settings is not None else None
add_item(
key="content_path_selected",
title="Content path selected",
completed=bool(content_path and content_path.strip()),
blocker="Required publishing settings are missing: content path.",
)
author = settings.author if settings is not None else None
add_item(
key="author_selected",
title="Author selected",
completed=bool(author and author.strip()),
blocker="Required publishing settings are missing: author.",
)
publishing_mode = settings.publishing_mode if settings is not None else None
add_item(
key="publishing_mode_selected",
title="Publishing mode selected",
completed=bool(publishing_mode and publishing_mode.strip()),
blocker="Required publishing settings are missing: publishing mode.",
)
can_approve = len(blockers) == 0
return FinalApprovalChecklistResponse(
article_id=article_id,
draft_version=latest_draft.version if latest_draft is not None else None,
requested_draft_version=requested_draft_version,
can_approve=can_approve,
completed_items=completed_items,
blockers=blockers,
items=checklist_items,
)
def approve_final_article(
repository: object,
*,
article_id: UUID,
request: FinalApprovalRequest,
actor_user_id: UUID,
) -> FinalApprovalResponse:
article = repository.articles.get(article_id)
checklist = get_final_approval_checklist(
repository,
article_id=article_id,
request=request,
)
if not checklist.can_approve:
raise PermissionError("; ".join(checklist.blockers))
now = _now()
updated = repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="FINAL_APPROVAL_GRANTED",
from_status=article.status,
to_status=ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
actor_user_id=actor_user_id,
payload={
"draft_version": checklist.draft_version,
"publishing_settings": (
request.publishing_settings.model_dump(mode="json")
if request.publishing_settings is not None
else {}
),
"completed_items": checklist.completed_items,
},
created_at=now,
)
return FinalApprovalResponse(article=updated, checklist=checklist)
def request_final_revision(
repository: object,
*,
article_id: UUID,
request: FinalRevisionRequest,
actor_user_id: UUID,
) -> FinalRevisionRequestResponse:
article = repository.articles.get(article_id)
checklist_request = FinalApprovalRequest(
draft_version=request.draft_version,
publishing_settings=None,
)
checklist = get_final_approval_checklist(
repository,
article_id=article_id,
request=checklist_request,
)
now = _now()
updated = repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="FINAL_REVISION_REQUESTED",
from_status=article.status,
to_status=ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
actor_user_id=actor_user_id,
payload={
"reason": (request.reason or "").strip(),
"draft_version": checklist.draft_version,
"blockers": checklist.blockers,
"completed_items": checklist.completed_items,
},
created_at=now,
)
return FinalRevisionRequestResponse(article=updated, checklist=checklist)
def _approved_plan(repository: object, *, article_id: UUID) -> object | None:
plans = repository.article_plans.list_for_article(article_id)
approved_plans = [plan for plan in plans if plan.status == PlanReviewStatus.APPROVED]
if not approved_plans:
return None
approved_plans.sort(key=lambda plan: plan.version)
return approved_plans[-1]
def _draft_contains_approved_plan(approved_plan: object | None, body_markdown: str) -> bool:
if approved_plan is None or not approved_plan.sections:
return False
for section in approved_plan.sections:
if f"## {section.heading}" not in body_markdown:
return False
return True
def _has_seo_metadata(draft: object) -> bool:
return bool(draft.meta_title and draft.meta_title.strip()) and bool(
draft.meta_description and draft.meta_description.strip()
)
def _has_unresolved_meta_issues(unresolved: list[object]) -> bool:
return any(
item.review_kind == ContentReviewKind.SEO
and item.location in {"meta_title", "meta_description", "title"}
for item in unresolved
)
def _has_internal_links(markdown: str) -> bool:
return re.search(r"\]\(/[^)]+\)", markdown) is not None
def _has_unresolved_internal_link_issue(unresolved: list[object]) -> bool:
for item in unresolved:
if item.suggestion_key == "internal-link":
return True
if "internal link" in item.message.lower():
return True
return False
def _high_risk_unsupported_claims(
repository: object,
*,
article_id: UUID,
draft: object | None,
) -> list[str]:
claims: list[str] = []
for claim in repository.claims.list_for_article(article_id):
if (
claim.support_status == ClaimSupportStatus.UNSUPPORTED
and claim.risk_level == ClaimRiskLevel.HIGH
):
claims.append(claim.claim_text)
for job in repository.agent_jobs.list_for_article(article_id):
if job.job_type != AgentJobType.SECTION_SCAFFOLD:
continue
raw_claims = job.payload.get("unsupported_claims")
if not isinstance(raw_claims, list):
continue
for raw in raw_claims:
if isinstance(raw, dict):
risk_level = str(raw.get("risk_level", "")).lower()
claim_text = str(raw.get("claim_text", "")).strip()
if risk_level == "high" and claim_text:
claims.append(claim_text)
if draft is not None:
for warning in draft.unsupported_claim_warnings:
if "high-risk" in warning.lower():
claims.append(warning)
deduped: list[str] = []
seen: set[str] = set()
for claim in claims:
normalized = claim.strip()
if not normalized:
continue
if normalized in seen:
continue
seen.add(normalized)
deduped.append(normalized)
return deduped
def _now() -> datetime:
return datetime.now(UTC)
@@ -0,0 +1,239 @@
from __future__ import annotations
import re
from datetime import datetime
from typing import Any
from src.domain.contracts import (
AgentJobStatus,
AgentJobSummary,
AgentJobType,
PublishingStatus,
Role,
WorkflowEventSummary,
)
_RETRYABLE_JOB_TYPES = {
AgentJobType.PLAN_GENERATION,
AgentJobType.RESEARCH,
AgentJobType.SECTION_SCAFFOLD,
AgentJobType.SEO_REVIEW,
AgentJobType.LANGUAGE_REVIEW,
AgentJobType.PUBLISH_COMMIT,
AgentJobType.TEST_CODEX,
}
_SENSITIVE_KEY_TOKENS = (
"token",
"password",
"secret",
"authorization",
"cookie",
"api_key",
"apikey",
"session",
)
_SENSITIVE_VALUE_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
(re.compile(r"(?i)(authorization\s*:\s*bearer\s+)[^\s]+"), r"\1[REDACTED]"),
(re.compile(r"(?i)(token\s*[=:]\s*)[^\s,;]+"), r"\1[REDACTED]"),
(re.compile(r"(?i)(password\s*[=:]\s*)[^\s,;]+"), r"\1[REDACTED]"),
(re.compile(r"(?i)(secret\s*[=:]\s*)[^\s,;]+"), r"\1[REDACTED]"),
(re.compile(r"(?i)(api[_-]?key\s*[=:]\s*)[^\s,;]+"), r"\1[REDACTED]"),
(re.compile(r"\b(sk-[a-zA-Z0-9_-]{8,})\b"), "[REDACTED]"),
(re.compile(r"\b(gh[pousr]_[a-zA-Z0-9]{8,})\b"), "[REDACTED]"),
)
def project_job_for_view(
repository: object,
job: AgentJobSummary,
*,
viewer_role: Role,
) -> AgentJobSummary:
retry_eligible, retry_block_reason = evaluate_retry_policy(repository, job)
payload = redact_json_value(job.payload)
error_message = _nullable_redacted(job.error_message)
stdout = redact_text(job.stdout)
stderr = redact_text(job.stderr)
safe_failure_summary = build_safe_failure_summary(job)
if viewer_role != Role.ADMIN:
stdout = ""
stderr = ""
return job.model_copy(
update={
"payload": payload,
"error_message": error_message,
"stdout": stdout,
"stderr": stderr,
"retry_eligible": retry_eligible,
"retry_block_reason": retry_block_reason,
"cancel_eligible": is_cancel_eligible(job),
"safe_failure_summary": safe_failure_summary,
},
deep=True,
)
def evaluate_retry_policy(
repository: object,
job: AgentJobSummary,
) -> tuple[bool, str | None]:
if job.status != AgentJobStatus.FAILED:
return False, "Retry is available only for failed jobs."
if job.job_type not in _RETRYABLE_JOB_TYPES:
return False, f"Retry is not supported for job type {job.job_type.value}."
if job.job_type == AgentJobType.PUBLISH_COMMIT:
if job.article_id is None:
return False, "Publish commit retry requires article context."
existing_publish_commit = repository.publish_commits.latest_for_article_with_statuses(
job.article_id,
statuses={PublishingStatus.PUBLISH_COMMIT_CREATED},
)
if existing_publish_commit is not None:
return (
False,
"Publish commit already exists for this article. Retry is blocked.",
)
return True, None
def is_cancel_eligible(job: AgentJobSummary) -> bool:
return job.status in {AgentJobStatus.QUEUED, AgentJobStatus.RUNNING}
def build_safe_failure_summary(job: AgentJobSummary) -> str | None:
if job.status != AgentJobStatus.FAILED:
return None
category = job.error_category.value if job.error_category is not None else "UNKNOWN"
step = (
_string_value(job.payload.get("last_successful_step"))
or _string_value(job.payload.get("artifact_label"))
or _string_value(job.payload.get("heading"))
)
message = redact_text(job.error_message or "").strip()
parts = [f"{job.job_type.value} failed ({category})."]
if step:
parts.append(f"Last successful step: {step}.")
if message:
parts.append(f"Summary: {message}.")
return " ".join(parts)
def build_observability_timeline(
workflow_events: list[WorkflowEventSummary],
agent_jobs: list[AgentJobSummary],
) -> list[dict[str, Any]]:
timeline: list[dict[str, Any]] = []
for event in workflow_events:
source = "USER" if event.actor_user_id is not None else "SYSTEM"
timeline.append(
{
"id": event.id,
"article_id": event.article_id,
"entry_type": "WORKFLOW_EVENT",
"source": source,
"event_type": event.event_type,
"from_status": event.from_status,
"to_status": event.to_status,
"actor_user_id": event.actor_user_id,
"job_id": None,
"job_type": None,
"job_status": None,
"retry_eligible": False,
"cancel_eligible": False,
"safe_failure_summary": None,
"payload": redact_json_value(event.payload),
"created_at": event.created_at,
}
)
for job in agent_jobs:
created_at = job.finished_at or job.started_at or job.queued_at
timeline.append(
{
"id": job.id,
"article_id": job.article_id,
"entry_type": "AGENT_JOB",
"source": "AGENT",
"event_type": f"{job.job_type.value}_{job.status.value}",
"from_status": None,
"to_status": None,
"actor_user_id": None,
"job_id": job.id,
"job_type": job.job_type,
"job_status": job.status,
"retry_eligible": bool(job.retry_eligible),
"cancel_eligible": bool(job.cancel_eligible),
"safe_failure_summary": job.safe_failure_summary,
"payload": {
"attempt": job.attempt,
"error_category": (
job.error_category.value if job.error_category is not None else None
),
"error_message": job.error_message,
},
"created_at": created_at,
}
)
timeline.sort(
key=lambda item: (
_timeline_dt(item.get("created_at")),
str(item.get("id")),
)
)
return timeline
def redact_json_value(value: Any) -> Any:
if isinstance(value, dict):
redacted: dict[str, Any] = {}
for key, nested in value.items():
if _contains_sensitive_token(key):
redacted[key] = "[REDACTED]"
else:
redacted[key] = redact_json_value(nested)
return redacted
if isinstance(value, list):
return [redact_json_value(item) for item in value]
if isinstance(value, str):
return redact_text(value)
return value
def redact_text(value: str) -> str:
redacted = value
for pattern, replacement in _SENSITIVE_VALUE_PATTERNS:
redacted = pattern.sub(replacement, redacted)
return redacted
def _timeline_dt(value: Any) -> datetime:
if isinstance(value, datetime):
return value
return datetime.min
def _contains_sensitive_token(value: str) -> bool:
normalized = value.strip().lower()
return any(token in normalized for token in _SENSITIVE_KEY_TOKENS)
def _string_value(value: Any) -> str:
if isinstance(value, str):
return value.strip()
return ""
def _nullable_redacted(value: str | None) -> str | None:
if value is None:
return None
redacted = redact_text(value).strip()
if not redacted:
return None
return redacted
+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)
+776
View File
@@ -0,0 +1,776 @@
from __future__ import annotations
import json
import re
import shutil
import subprocess
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from uuid import UUID
from src.infrastructure.object_storage import open_object_storage_client
from src.domain.contracts import (
ArticleSummary,
ArticleWorkflowStatus,
PublishCommitCreateResponse,
PublishCommitListResponse,
PublishCommitSummary,
PublishingDryRunResponse,
PublishingStatus,
PublishingStatusResponse,
)
_VALIDATION_LABEL = "Best-effort content-shape validation only."
_FINAL_APPROVAL_EVENT = "FINAL_APPROVAL_GRANTED"
_RUNTIME_DIR = ".pipeline-runtime"
_PUBLISHING_YAML_PATH = f"{_RUNTIME_DIR}/publishing.yaml"
_TRANSFORM_SCRIPT_PATH = f"{_RUNTIME_DIR}/transform.mjs"
_TRANSFORM_RUNNER_PATH = f"{_RUNTIME_DIR}/run-transform.mjs"
_TRANSFORM_INPUT_PATH = f"{_RUNTIME_DIR}/transform-input.json"
_TRANSFORM_OUTPUT_PATH = f"{_RUNTIME_DIR}/transform-output.json"
def run_publishing_dry_run(
repository: object,
*,
article_id: UUID,
actor_user_id: UUID,
) -> PublishingDryRunResponse:
article = repository.articles.get(article_id)
final_approval_event = _require_final_approval(repository, article_id=article_id)
if article.status not in {
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
}:
raise PermissionError("Publishing dry run is allowed only after final approval.")
bundle_context = _load_bundle_context(
repository,
article=article,
final_approval_event=final_approval_event,
)
validation_errors = _validate_content_shape(bundle_context["markdown_body"])
content_shape_valid = len(validation_errors) == 0
now = _now()
status = (
PublishingStatus.PUBLISH_COMMIT_READY
if content_shape_valid
else PublishingStatus.PUBLISH_DRY_RUN_FAILED
)
manifest = _build_manifest(
bundle_context=bundle_context,
status=status,
validation_errors=validation_errors,
)
publish_commit = repository.publish_commits.create(
article_id=article.id,
target_site_id=article.target_site_id,
repository_url=bundle_context["repository_url"],
branch=bundle_context["branch"],
commit_sha=None,
content_bundle_manifest=manifest,
status=status,
deployment_status=None,
created_at=now,
)
updated = _update_article_for_dry_run_result(
repository,
article=article,
content_shape_valid=content_shape_valid,
actor_user_id=actor_user_id,
validation_errors=validation_errors,
created_at=now,
)
return PublishingDryRunResponse(
article=updated,
publish_commit=publish_commit,
content_shape_valid=content_shape_valid,
validation_label=_VALIDATION_LABEL,
errors=validation_errors,
)
def create_publish_commit(
repository: object,
*,
article_id: UUID,
actor_user_id: UUID,
) -> PublishCommitCreateResponse:
article = repository.articles.get(article_id)
_require_final_approval(repository, article_id=article_id)
latest_dry_run = repository.publish_commits.latest_for_article_with_statuses(
article_id,
statuses={PublishingStatus.PUBLISH_COMMIT_READY},
)
if latest_dry_run is None:
raise PermissionError("Publish commit requires successful dry run.")
if article.status != ArticleWorkflowStatus.PUBLISH_COMMIT_READY:
raise PermissionError("Publish commit can run only from PUBLISH_COMMIT_READY status.")
manifest = dict(latest_dry_run.content_bundle_manifest or {})
git_info = manifest.get("git") if isinstance(manifest.get("git"), dict) else {}
expected_base_head_sha = git_info.get("base_head_sha")
repository_url = latest_dry_run.repository_url
branch = latest_dry_run.branch
current_head_sha = _resolve_remote_branch_head_sha(repository_url, branch)
if expected_base_head_sha and current_head_sha != expected_base_head_sha:
_record_publish_failure(
repository,
article=article,
actor_user_id=actor_user_id,
repository_url=repository_url,
branch=branch,
previous_manifest=manifest,
detail=(
"Non-fast-forward detected: remote branch advanced since dry run; "
"re-run dry run before creating commit."
),
)
raise PermissionError(
"Non-fast-forward detected: remote branch advanced since dry run."
)
final_approval_event = _require_final_approval(repository, article_id=article_id)
bundle_context = _load_bundle_context(
repository,
article=article,
final_approval_event=final_approval_event,
)
commit_sha = _create_and_push_commit(bundle_context)
now = _now()
publish_commit = repository.publish_commits.create(
article_id=article.id,
target_site_id=article.target_site_id,
repository_url=repository_url,
branch=branch,
commit_sha=commit_sha,
content_bundle_manifest=_build_manifest(
bundle_context=bundle_context,
status=PublishingStatus.PUBLISH_COMMIT_CREATED,
validation_errors=[],
commit_sha=commit_sha,
),
status=PublishingStatus.PUBLISH_COMMIT_CREATED,
deployment_status="PENDING",
created_at=now,
)
updated = repository.articles.update_status(
article_id=article.id,
status=ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED,
updated_at=now,
)
updated = repository.articles.update_publishing_status(
article_id=article.id,
publishing_status=PublishingStatus.PUBLISH_COMMIT_CREATED,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article.id,
event_type="PUBLISH_COMMIT_CREATED",
from_status=article.status,
to_status=ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED,
actor_user_id=actor_user_id,
payload={
"publish_commit_id": str(publish_commit.id),
"repository_url": repository_url,
"branch": branch,
"commit_sha": commit_sha,
},
created_at=now,
)
return PublishCommitCreateResponse(article=updated, publish_commit=publish_commit)
def get_publishing_status(
repository: object,
*,
article_id: UUID,
) -> PublishingStatusResponse:
article = repository.articles.get(article_id)
latest_dry_run = repository.publish_commits.latest_for_article_with_statuses(
article_id,
statuses={PublishingStatus.PUBLISH_COMMIT_READY, PublishingStatus.PUBLISH_DRY_RUN_FAILED},
)
latest_publish_commit = repository.publish_commits.latest_for_article_with_statuses(
article_id,
statuses={PublishingStatus.PUBLISH_COMMIT_CREATED},
)
return PublishingStatusResponse(
article=article,
latest_dry_run=latest_dry_run,
latest_publish_commit=latest_publish_commit,
validation_label=_VALIDATION_LABEL,
)
def list_publish_commits(
repository: object,
*,
article_id: UUID,
) -> PublishCommitListResponse:
repository.articles.get(article_id)
return PublishCommitListResponse(commits=repository.publish_commits.list_for_article(article_id))
def _load_bundle_context(
repository: object,
*,
article: ArticleSummary,
final_approval_event: object,
) -> dict[str, Any]:
target_site = repository.target_sites.get_by_id(article.target_site_id)
if target_site.active_script_config_version_id is None:
raise PermissionError("Active script config version is required for publishing.")
script_config_version = repository.script_config_versions.get_by_id(
target_site.active_script_config_version_id
)
draft = repository.article_drafts.latest_for_article(article.id)
if draft is None:
raise PermissionError("Latest draft is required for publishing.")
settings_payload = final_approval_event.payload.get("publishing_settings", {})
if not isinstance(settings_payload, dict):
settings_payload = {}
content_path = _string_value(settings_payload.get("content_path"))
if not content_path:
raise PermissionError("Final approval publishing settings are missing content_path.")
author = _string_value(settings_payload.get("author"))
if not author:
raise PermissionError("Final approval publishing settings are missing author.")
frontmatter_input = (
settings_payload.get("frontmatter")
if isinstance(settings_payload.get("frontmatter"), dict)
else {}
)
slug = _slug_from_content_path(content_path) or _slugify(draft.title or article.working_title or "article")
content_rel_path = _apply_template(
target_site.publishing_rules.content_path_template,
{
"slug": slug,
"article_id": str(article.id),
"content_path": content_path.lstrip("/"),
"format": target_site.publishing_rules.content_format,
"language": article.language,
},
)
frontmatter = _build_frontmatter(
frontmatter_mapping=target_site.publishing_rules.frontmatter_mapping,
frontmatter_input=frontmatter_input,
author=author,
draft=draft,
published_at=_now().isoformat(),
)
markdown_with_frontmatter = _compose_markdown(frontmatter, draft.body_markdown)
object_storage = open_object_storage_client()
assets = _collect_assets(
repository,
article_id=article.id,
slug=slug,
asset_path_template=target_site.publishing_rules.asset_path_template,
object_storage=object_storage,
)
return {
"article": article,
"draft": draft,
"target_site": target_site,
"script_config_version": script_config_version,
"slug": slug,
"content_rel_path": content_rel_path,
"frontmatter": frontmatter,
"markdown_body": draft.body_markdown,
"markdown_with_frontmatter": markdown_with_frontmatter,
"assets": assets,
"repository_url": target_site.publishing_rules.repository_url,
"branch": target_site.publishing_rules.production_branch,
"content_format": target_site.publishing_rules.content_format,
}
def _build_frontmatter(
*,
frontmatter_mapping: dict[str, Any],
frontmatter_input: dict[str, Any],
author: str,
draft: object,
published_at: str,
) -> dict[str, Any]:
source = {
**frontmatter_input,
"title": frontmatter_input.get("title") or getattr(draft, "title", None),
"meta_description": getattr(draft, "meta_description", None),
"author": author,
"published_at": published_at,
}
mapped: dict[str, Any] = {}
for output_key, source_key in frontmatter_mapping.items():
if not isinstance(output_key, str) or not output_key:
continue
if not isinstance(source_key, str) or not source_key:
continue
if source_key in source and source[source_key] is not None:
mapped[output_key] = source[source_key]
for key, value in frontmatter_input.items():
if key not in mapped:
mapped[key] = value
if "author" not in mapped:
mapped["author"] = author
return mapped
def _collect_assets(
repository: object,
*,
article_id: UUID,
slug: str,
asset_path_template: str,
object_storage: object,
) -> list[dict[str, Any]]:
assets = repository.assets.list_for_article(article_id)
output: list[dict[str, Any]] = []
for asset in assets:
if asset.status.value != "APPROVED":
continue
if not asset.file_url:
continue
source_file_name = _asset_filename(asset)
source_content = _load_asset_bytes(
asset=asset,
object_storage=object_storage,
)
if source_content is None:
raise PermissionError(f"Approved asset file is unavailable: {asset.file_url}")
target_path = _apply_template(
asset_path_template,
{
"slug": slug,
"filename": source_file_name,
"article_id": str(article_id),
"asset_id": str(asset.id),
},
)
output.append(
{
"asset_id": str(asset.id),
"asset_type": asset.asset_type.value,
"source_url": asset.file_url,
"source_content": source_content,
"target_path": target_path,
}
)
return output
def _create_and_push_commit(bundle_context: dict[str, Any]) -> str:
repository_url = bundle_context["repository_url"]
branch = bundle_context["branch"]
with tempfile.TemporaryDirectory(prefix="publish-commit-") as tmp_dir:
workspace = Path(tmp_dir) / "site"
_run_cmd(
["git", "clone", "--branch", branch, "--single-branch", repository_url, str(workspace)],
cwd=None,
error_prefix="GIT_CHECKOUT_FAILED",
)
runtime_dir = workspace / _RUNTIME_DIR
runtime_dir.mkdir(parents=True, exist_ok=True)
script_config = bundle_context["script_config_version"]
(workspace / _PUBLISHING_YAML_PATH).write_text(
str(script_config["publishing_yaml"]),
encoding="utf-8",
)
(workspace / _TRANSFORM_SCRIPT_PATH).write_text(
str(script_config["transform_script"]),
encoding="utf-8",
)
transform_assets = [
{
"asset_id": asset["asset_id"],
"asset_type": asset["asset_type"],
"source_url": asset["source_url"],
"target_path": asset["target_path"],
}
for asset in bundle_context["assets"]
]
transform_input = {
"frontmatter": bundle_context["frontmatter"],
"body": bundle_context["markdown_body"],
"assets": transform_assets,
"content_path": bundle_context["content_rel_path"],
}
(workspace / _TRANSFORM_INPUT_PATH).write_text(
json.dumps(transform_input, ensure_ascii=True, indent=2),
encoding="utf-8",
)
(workspace / _TRANSFORM_RUNNER_PATH).write_text(
_transform_runner_script(),
encoding="utf-8",
)
_run_cmd(
[
"node",
_TRANSFORM_RUNNER_PATH,
_TRANSFORM_SCRIPT_PATH,
_TRANSFORM_INPUT_PATH,
_TRANSFORM_OUTPUT_PATH,
],
cwd=workspace,
error_prefix="PUBLISH_DRY_RUN_FAILED",
)
content_file = workspace / bundle_context["content_rel_path"]
content_file.parent.mkdir(parents=True, exist_ok=True)
content_file.write_text(bundle_context["markdown_with_frontmatter"], encoding="utf-8")
for asset in bundle_context["assets"]:
target_path = workspace / str(asset["target_path"])
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(bytes(asset["source_content"]))
shutil.rmtree(runtime_dir, ignore_errors=True)
_run_cmd(
["git", "config", "user.name", "Pipeline Bot"],
cwd=workspace,
error_prefix="GIT_COMMIT_FAILED",
)
_run_cmd(
["git", "config", "user.email", "pipeline-bot@example.com"],
cwd=workspace,
error_prefix="GIT_COMMIT_FAILED",
)
_run_cmd(["git", "add", "."], cwd=workspace, error_prefix="GIT_COMMIT_FAILED")
commit_message = (
f"Publish article {bundle_context['article'].id}: {bundle_context['draft'].title}"
)
_run_cmd(
["git", "commit", "--allow-empty", "-m", commit_message],
cwd=workspace,
error_prefix="GIT_COMMIT_FAILED",
)
try:
_run_cmd(
["git", "push", "origin", branch],
cwd=workspace,
error_prefix="GIT_PUSH_NON_FAST_FORWARD",
)
except RuntimeError as error:
if "non-fast-forward" in str(error) or "[rejected]" in str(error):
raise PermissionError(
"Non-fast-forward push rejected. Re-run dry run and retry commit."
) from error
raise
commit_sha = _run_cmd(
["git", "rev-parse", "HEAD"],
cwd=workspace,
error_prefix="GIT_COMMIT_FAILED",
).strip()
return commit_sha
def _update_article_for_dry_run_result(
repository: object,
*,
article: ArticleSummary,
content_shape_valid: bool,
actor_user_id: UUID,
validation_errors: list[str],
created_at: datetime,
) -> ArticleSummary:
if content_shape_valid:
updated = repository.articles.update_status(
article_id=article.id,
status=ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
updated_at=created_at,
)
updated = repository.articles.update_publishing_status(
article_id=article.id,
publishing_status=PublishingStatus.PUBLISH_COMMIT_READY,
updated_at=created_at,
)
repository.articles.create_workflow_event(
article_id=article.id,
event_type="PUBLISH_DRY_RUN_SUCCEEDED",
from_status=article.status,
to_status=ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
actor_user_id=actor_user_id,
payload={"validation_label": _VALIDATION_LABEL},
created_at=created_at,
)
return updated
updated = repository.articles.update_status(
article_id=article.id,
status=ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
updated_at=created_at,
)
updated = repository.articles.update_publishing_status(
article_id=article.id,
publishing_status=PublishingStatus.PUBLISH_DRY_RUN_FAILED,
updated_at=created_at,
)
repository.articles.create_workflow_event(
article_id=article.id,
event_type="PUBLISH_DRY_RUN_FAILED",
from_status=article.status,
to_status=ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
actor_user_id=actor_user_id,
payload={
"validation_label": _VALIDATION_LABEL,
"errors": validation_errors,
},
created_at=created_at,
)
return updated
def _record_publish_failure(
repository: object,
*,
article: ArticleSummary,
actor_user_id: UUID,
repository_url: str,
branch: str,
previous_manifest: dict[str, Any],
detail: str,
) -> None:
now = _now()
failed_manifest = dict(previous_manifest)
failed_manifest["failure"] = detail
repository.publish_commits.create(
article_id=article.id,
target_site_id=article.target_site_id,
repository_url=repository_url,
branch=branch,
commit_sha=None,
content_bundle_manifest=failed_manifest,
status=PublishingStatus.PUBLISH_VERIFICATION_FAILED,
deployment_status="FAILED",
created_at=now,
)
repository.articles.update_publishing_status(
article_id=article.id,
publishing_status=PublishingStatus.PUBLISH_VERIFICATION_FAILED,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article.id,
event_type="PUBLISH_COMMIT_FAILED",
from_status=article.status,
to_status=article.status,
actor_user_id=actor_user_id,
payload={"detail": detail},
created_at=now,
)
def _build_manifest(
*,
bundle_context: dict[str, Any],
status: PublishingStatus,
validation_errors: list[str],
commit_sha: str | None = None,
) -> dict[str, Any]:
base_head_sha = _resolve_remote_branch_head_sha(
bundle_context["repository_url"], bundle_context["branch"]
)
script_config_version = bundle_context["script_config_version"]
return {
"status": status.value,
"generated_at": _now().isoformat(),
"validation": {
"label": _VALIDATION_LABEL,
"content_shape_valid": len(validation_errors) == 0,
"errors": validation_errors,
},
"content": {
"format": bundle_context["content_format"],
"path": bundle_context["content_rel_path"],
"slug": bundle_context["slug"],
},
"frontmatter": bundle_context["frontmatter"],
"assets": [
{
"asset_id": asset["asset_id"],
"asset_type": asset["asset_type"],
"source_url": asset["source_url"],
"target_path": asset["target_path"],
}
for asset in bundle_context["assets"]
],
"config_version": {
"version_id": str(script_config_version["id"]),
"version": int(script_config_version["version"]),
"publishing_yaml_hash": str(script_config_version["publishing_yaml_hash"]),
"transform_script_hash": str(script_config_version["transform_script_hash"]),
},
"git": {
"repository_url": bundle_context["repository_url"],
"branch": bundle_context["branch"],
"base_head_sha": base_head_sha,
"commit_sha": commit_sha,
},
"draft_version": int(bundle_context["draft"].version),
}
def _validate_content_shape(markdown: str) -> list[str]:
errors: list[str] = []
if not markdown.strip():
errors.append("Markdown body is empty.")
if re.search(r"^#{1,6}\s+\S", markdown, re.MULTILINE) is None:
errors.append("Markdown body must include at least one heading.")
if markdown.count("```") % 2 != 0:
errors.append("Markdown body has unbalanced fenced code blocks.")
return errors
def _compose_markdown(frontmatter: dict[str, Any], markdown_body: str) -> str:
lines = ["---"]
for key in sorted(frontmatter):
lines.append(f"{key}: {json.dumps(frontmatter[key], ensure_ascii=True)}")
lines.extend(["---", "", markdown_body.strip(), ""])
return "\n".join(lines)
def _resolve_remote_branch_head_sha(repository_url: str, branch: str) -> str | None:
output = _run_cmd(
["git", "ls-remote", repository_url, f"refs/heads/{branch}"],
cwd=None,
error_prefix="GIT_CHECKOUT_FAILED",
)
line = output.strip()
if not line:
return None
return line.split("\t", 1)[0]
def _run_cmd(
command: list[str],
*,
cwd: Path | None,
error_prefix: str,
) -> str:
result = subprocess.run(
command,
cwd=str(cwd) if cwd is not None else None,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
message = result.stderr.strip() or result.stdout.strip() or "command failed"
raise RuntimeError(f"{error_prefix}: {message}")
return result.stdout
def _apply_template(template: str, values: dict[str, Any]) -> str:
class _SafeValues(dict[str, Any]):
def __missing__(self, key: str) -> str:
return "{" + key + "}"
rendered = template.format_map(_SafeValues(values))
return rendered.lstrip("/")
def _local_path_from_file_url(file_url: str) -> Path | None:
parsed = urlparse(file_url)
if parsed.scheme != "file":
return None
return Path(parsed.path)
def _asset_filename(asset: object) -> str:
source_path = _local_path_from_file_url(_string_value(getattr(asset, "file_url", None)))
if source_path is not None:
return source_path.name
object_key = _string_value(getattr(asset, "object_key", None))
if object_key:
return Path(object_key).name
return "asset.bin"
def _load_asset_bytes(*, asset: object, object_storage: object) -> bytes | None:
file_url = _string_value(getattr(asset, "file_url", None))
local_path = _local_path_from_file_url(file_url)
if local_path is not None:
if not local_path.exists():
return None
return local_path.read_bytes()
object_key = _string_value(getattr(asset, "object_key", None))
parsed = urlparse(file_url)
if parsed.scheme == "s3" and not object_key:
object_key = parsed.path.lstrip("/")
if not object_key:
return None
try:
return object_storage.get_bytes(object_key=object_key)
except Exception:
return None
def _require_final_approval(repository: object, *, article_id: UUID) -> object:
events = repository.articles.list_workflow_events(article_id)
for event in reversed(events):
if event.event_type == _FINAL_APPROVAL_EVENT:
return event
raise PermissionError("Final approval is required before publishing.")
def _slug_from_content_path(content_path: str) -> str:
value = content_path.strip().strip("/")
if not value:
return ""
tail = value.split("/")[-1]
if "." in tail:
tail = tail.rsplit(".", 1)[0]
return _slugify(tail)
def _slugify(value: str) -> str:
normalized = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-")
return normalized or "article"
def _string_value(value: Any) -> str:
if isinstance(value, str):
return value.strip()
return ""
def _transform_runner_script() -> str:
return """
import { readFileSync, writeFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
const [scriptPath, inputPath, outputPath] = process.argv.slice(2);
const scriptUrl = pathToFileURL(scriptPath).href;
const moduleRef = await import(scriptUrl + `?t=${Date.now()}`);
const transform =
typeof moduleRef.transformArticle === "function"
? moduleRef.transformArticle
: typeof moduleRef.default === "function"
? moduleRef.default
: null;
if (!transform) {
throw new Error("Transform script must export transformArticle(article).");
}
const raw = readFileSync(inputPath, "utf8");
const article = JSON.parse(raw);
const transformed = await transform(article);
const output = transformed ?? article;
writeFileSync(outputPath, JSON.stringify(output, null, 2), "utf8");
""".strip()
def _now() -> datetime:
return datetime.now(UTC)
+181
View File
@@ -0,0 +1,181 @@
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],
payload={},
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)
+645
View File
@@ -0,0 +1,645 @@
from __future__ import annotations
import re
from datetime import UTC, datetime
from uuid import NAMESPACE_URL, UUID, uuid5
from src.application.drafts import update_draft
from src.domain.contracts import (
ArticleWorkflowStatus,
ContentReviewKind,
ContentReviewReportSummary,
ContentReviewSuggestionSummary,
DraftSummary,
DraftUpdateRequest,
FinalReviewIssueSummary,
FinalReviewIssuesResponse,
LanguageReviewReportResponse,
LanguageReviewRunResponse,
ReviewSuggestionActionResponse,
ReviewSuggestionEditRequest,
ReviewSuggestionStatus,
SeoReviewReportResponse,
SeoReviewRunResponse,
)
DEFAULT_SEO_RULES = {
"title_min": 30,
"title_max": 60,
"meta_title_min": 30,
"meta_title_max": 60,
"meta_description_min": 120,
"meta_description_max": 160,
"schema_type": "Article",
}
DEFAULT_LANGUAGE_RULES = {
"max_sentence_words": 24,
"forbidden_phrases": ["best ever", "guaranteed"],
"brand_tone": "neutral",
}
def run_seo_review(repository: object, *, article_id: UUID) -> SeoReviewRunResponse:
article = repository.articles.get(article_id)
draft = _require_latest_draft(repository, article_id=article_id)
site = repository.target_sites.get_by_id(article.target_site_id)
rules = _resolve_seo_rules(site.seo_rules)
issues = _build_seo_issues(
article_id=article_id,
title=draft.title,
meta_title=draft.meta_title or draft.title,
meta_description=draft.meta_description or "",
body_markdown=draft.body_markdown,
primary_keyword=article.primary_keyword or "",
rules=rules,
)
recommended_title = _recommended_title(
title=draft.title,
primary_keyword=article.primary_keyword or "",
)
recommended_slug = _slugify(recommended_title)
score = max(0, 100 - len(issues) * 12)
now = _now()
report = repository.content_reviews.create_report(
article_id=article_id,
review_kind=ContentReviewKind.SEO,
draft_id=draft.id,
score=score,
recommended_slug=recommended_slug,
recommended_title=recommended_title,
schema_json={
"@context": "https://schema.org",
"@type": str(rules.get("schema_type", "Article")),
"headline": recommended_title,
"inLanguage": article.language,
},
rules_snapshot=rules,
created_at=now,
)
_upsert_report_suggestions(
repository,
article_id=article_id,
report_id=report.id,
review_kind=ContentReviewKind.SEO,
issues=issues,
timestamp=now,
)
latest = repository.content_reviews.get_report(
article_id=article_id,
report_id=report.id,
)
_mark_review_ready(repository, article_id=article_id, event_type="SEO_REVIEW_COMPLETED", count=len(latest.issues))
return SeoReviewRunResponse(report=latest)
def get_seo_report(repository: object, *, article_id: UUID) -> SeoReviewReportResponse:
repository.articles.get(article_id)
report = repository.content_reviews.latest_report(
article_id=article_id,
review_kind=ContentReviewKind.SEO,
)
if report is None:
raise LookupError(f"SEO report not found for article: {article_id}")
return SeoReviewReportResponse(report=report)
def run_language_review(repository: object, *, article_id: UUID) -> LanguageReviewRunResponse:
article = repository.articles.get(article_id)
draft = _require_latest_draft(repository, article_id=article_id)
site = repository.target_sites.get_by_id(article.target_site_id)
rules = _resolve_language_rules(site.seo_rules, brand_voice=site.brand_voice)
issues = _build_language_issues(
article_id=article_id,
body_markdown=draft.body_markdown,
rules=rules,
)
score = max(0, 100 - len(issues) * 14)
now = _now()
report = repository.content_reviews.create_report(
article_id=article_id,
review_kind=ContentReviewKind.LANGUAGE,
draft_id=draft.id,
score=score,
recommended_slug=draft.slug,
recommended_title=draft.title,
schema_json={
"tone": rules.get("brand_tone"),
"language": article.language,
"max_sentence_words": rules.get("max_sentence_words"),
},
rules_snapshot=rules,
created_at=now,
)
_upsert_report_suggestions(
repository,
article_id=article_id,
report_id=report.id,
review_kind=ContentReviewKind.LANGUAGE,
issues=issues,
timestamp=now,
)
latest = repository.content_reviews.get_report(
article_id=article_id,
report_id=report.id,
)
_mark_review_ready(
repository,
article_id=article_id,
event_type="LANGUAGE_REVIEW_COMPLETED",
count=len(latest.issues),
)
return LanguageReviewRunResponse(report=latest)
def get_language_report(
repository: object,
*,
article_id: UUID,
) -> LanguageReviewReportResponse:
repository.articles.get(article_id)
report = repository.content_reviews.latest_report(
article_id=article_id,
review_kind=ContentReviewKind.LANGUAGE,
)
if report is None:
raise LookupError(f"Language report not found for article: {article_id}")
return LanguageReviewReportResponse(report=report)
def accept_suggestion(
repository: object,
*,
article_id: UUID,
review_kind: ContentReviewKind,
suggestion_id: UUID,
) -> ReviewSuggestionActionResponse:
suggestion = repository.content_reviews.get_suggestion(
article_id=article_id,
review_kind=review_kind,
suggestion_id=suggestion_id,
)
draft = _apply_suggestion_to_draft(
repository,
article_id=article_id,
suggestion=suggestion,
override_text=None,
)
updated = repository.content_reviews.update_suggestion(
article_id=article_id,
review_kind=review_kind,
suggestion_id=suggestion_id,
status=ReviewSuggestionStatus.ACCEPTED,
applied_text=None,
updated_at=_now(),
)
return ReviewSuggestionActionResponse(suggestion=updated, draft=draft)
def reject_suggestion(
repository: object,
*,
article_id: UUID,
review_kind: ContentReviewKind,
suggestion_id: UUID,
) -> ReviewSuggestionActionResponse:
repository.articles.get(article_id)
updated = repository.content_reviews.update_suggestion(
article_id=article_id,
review_kind=review_kind,
suggestion_id=suggestion_id,
status=ReviewSuggestionStatus.REJECTED,
applied_text=None,
updated_at=_now(),
)
return ReviewSuggestionActionResponse(suggestion=updated, draft=None)
def edit_suggestion(
repository: object,
*,
article_id: UUID,
review_kind: ContentReviewKind,
suggestion_id: UUID,
request: ReviewSuggestionEditRequest,
) -> ReviewSuggestionActionResponse:
suggestion = repository.content_reviews.get_suggestion(
article_id=article_id,
review_kind=review_kind,
suggestion_id=suggestion_id,
)
draft = _apply_suggestion_to_draft(
repository,
article_id=article_id,
suggestion=suggestion,
override_text=request.applied_text,
)
updated = repository.content_reviews.update_suggestion(
article_id=article_id,
review_kind=review_kind,
suggestion_id=suggestion_id,
status=ReviewSuggestionStatus.EDITED,
applied_text=request.applied_text,
updated_at=_now(),
)
return ReviewSuggestionActionResponse(suggestion=updated, draft=draft)
def get_final_review_issues(
repository: object,
*,
article_id: UUID,
) -> FinalReviewIssuesResponse:
repository.articles.get(article_id)
unresolved = repository.content_reviews.list_unresolved_for_article(article_id=article_id)
return FinalReviewIssuesResponse(
article_id=article_id,
unresolved_count=len(unresolved),
unresolved_issues=[
FinalReviewIssueSummary(
review_kind=item.review_kind,
suggestion_id=item.id,
severity=item.severity,
location=item.location,
message=item.message,
status=item.status,
)
for item in unresolved
],
)
def _require_latest_draft(repository: object, *, article_id: UUID) -> DraftSummary:
draft = repository.article_drafts.latest_for_article(article_id)
if draft is None:
raise PermissionError("Assembled draft is required before review")
return draft
def _resolve_seo_rules(seo_rules: dict[str, object]) -> dict[str, object]:
overrides = seo_rules.get("seo_review", {}) if isinstance(seo_rules, dict) else {}
result = dict(DEFAULT_SEO_RULES)
if isinstance(overrides, dict):
result.update(overrides)
return result
def _resolve_language_rules(
seo_rules: dict[str, object],
*,
brand_voice: str,
) -> dict[str, object]:
overrides = seo_rules.get("language_review", {}) if isinstance(seo_rules, dict) else {}
result = dict(DEFAULT_LANGUAGE_RULES)
if isinstance(overrides, dict):
result.update(overrides)
if not result.get("brand_tone"):
result["brand_tone"] = brand_voice
return result
def _build_seo_issues(
*,
article_id: UUID,
title: str,
meta_title: str,
meta_description: str,
body_markdown: str,
primary_keyword: str,
rules: dict[str, object],
) -> list[dict[str, object]]:
issues: list[dict[str, object]] = []
title_min = int(rules.get("meta_title_min", 30))
title_max = int(rules.get("meta_title_max", 60))
if len(meta_title) < title_min or len(meta_title) > title_max:
suggested = _trim(meta_title or title, min_len=title_min, max_len=title_max)
issues.append(
{
"id": "meta-title-length",
"severity": "HIGH",
"location": "meta_title",
"message": f"Meta title length should be between {title_min} and {title_max} characters.",
"suggested_fix": f"Use a {title_min}-{title_max} character meta title.",
"suggested_rewrite": suggested,
"patch": {"field": "meta_title", "value": suggested},
}
)
desc_min = int(rules.get("meta_description_min", 120))
desc_max = int(rules.get("meta_description_max", 160))
if len(meta_description) < desc_min or len(meta_description) > desc_max:
suggested_description = _trim(meta_description or title, min_len=desc_min, max_len=desc_max)
issues.append(
{
"id": "meta-description-length",
"severity": "HIGH",
"location": "meta_description",
"message": f"Meta description length should be between {desc_min} and {desc_max} characters.",
"suggested_fix": "Adjust meta description length to fit SEO rules.",
"suggested_rewrite": suggested_description,
"patch": {"field": "meta_description", "value": suggested_description},
}
)
if primary_keyword:
normalized_keyword = primary_keyword.lower()
if normalized_keyword not in body_markdown.lower():
append_sentence = f" This guide addresses {primary_keyword} with concrete steps."
issues.append(
{
"id": "keyword-placement",
"severity": "MEDIUM",
"location": "body_markdown",
"message": "Primary keyword is missing from draft body.",
"suggested_fix": "Add primary keyword to introduction paragraph.",
"suggested_rewrite": append_sentence.strip(),
"patch": {
"field": "body_append",
"value": append_sentence,
},
}
)
if "](/" not in body_markdown and "](/" not in body_markdown:
issues.append(
{
"id": "internal-link",
"severity": "MEDIUM",
"location": "body_markdown",
"message": "No internal links found in the draft.",
"suggested_fix": "Add at least one internal link to a related page.",
"suggested_rewrite": "Add [related guide](/guides/related-topic) in the intro section.",
"patch": {},
}
)
headings = re.findall(r"^##\s+(.+)$", body_markdown, flags=re.MULTILINE)
duplicate = _first_duplicate(headings)
if duplicate:
issues.append(
{
"id": "duplicate-h2",
"severity": "LOW",
"location": "body_markdown",
"message": f"Duplicate H2 heading detected: {duplicate}",
"suggested_fix": "Rename duplicate headings to keep a clear document structure.",
"suggested_rewrite": None,
"patch": {},
}
)
issues.sort(key=lambda item: str(item["id"]))
return issues
def _build_language_issues(
*,
article_id: UUID,
body_markdown: str,
rules: dict[str, object],
) -> list[dict[str, object]]:
del article_id
issues: list[dict[str, object]] = []
max_sentence_words = int(rules.get("max_sentence_words", 24))
first_long_sentence = _first_sentence_longer_than(body_markdown, max_sentence_words)
if first_long_sentence:
rewrite = _shorten_sentence(first_long_sentence, max_words=max_sentence_words)
issues.append(
{
"id": "long-sentence",
"severity": "MEDIUM",
"location": "body_markdown",
"message": f"Sentence exceeds {max_sentence_words} words.",
"suggested_fix": "Split or shorten long sentence.",
"suggested_rewrite": rewrite,
"patch": {
"field": "body_replace",
"target": first_long_sentence,
"value": rewrite,
},
}
)
forbidden_phrases = rules.get("forbidden_phrases", [])
if isinstance(forbidden_phrases, list):
lower_body = body_markdown.lower()
for phrase in [str(item) for item in forbidden_phrases]:
if phrase.lower() in lower_body:
issues.append(
{
"id": f"forbidden-{_slugify(phrase)}",
"severity": "HIGH",
"location": "body_markdown",
"message": f"Forbidden phrase found: '{phrase}'.",
"suggested_fix": "Replace phrase with neutral wording.",
"suggested_rewrite": "Replace with measured, evidence-based language.",
"patch": {},
}
)
break
tone = str(rules.get("brand_tone", "neutral"))
if tone and tone.lower() in {"strict", "formal"} and "!" in body_markdown:
issues.append(
{
"id": "tone-exclamation",
"severity": "LOW",
"location": "body_markdown",
"message": "Exclamation marks conflict with strict/formal tone.",
"suggested_fix": "Use neutral punctuation.",
"suggested_rewrite": None,
"patch": {},
}
)
issues.sort(key=lambda item: str(item["id"]))
return issues
def _upsert_report_suggestions(
repository: object,
*,
article_id: UUID,
report_id: UUID,
review_kind: ContentReviewKind,
issues: list[dict[str, object]],
timestamp: datetime,
) -> None:
for issue in issues:
suggestion_id = _deterministic_suggestion_id(
article_id=article_id,
review_kind=review_kind,
suggestion_key=str(issue["id"]),
)
repository.content_reviews.upsert_suggestion(
suggestion_id=suggestion_id,
article_id=article_id,
review_kind=review_kind,
report_id=report_id,
suggestion_key=str(issue["id"]),
severity=str(issue["severity"]),
location=str(issue["location"]),
message=str(issue["message"]),
suggested_fix=_optional_string(issue.get("suggested_fix")),
suggested_rewrite=_optional_string(issue.get("suggested_rewrite")),
patch=issue.get("patch", {}) if isinstance(issue.get("patch"), dict) else {},
created_at=timestamp,
updated_at=timestamp,
)
def _deterministic_suggestion_id(
*,
article_id: UUID,
review_kind: ContentReviewKind,
suggestion_key: str,
) -> UUID:
return uuid5(
NAMESPACE_URL,
f"{article_id}:{review_kind.value}:{suggestion_key}",
)
def _apply_suggestion_to_draft(
repository: object,
*,
article_id: UUID,
suggestion: ContentReviewSuggestionSummary,
override_text: str | None,
) -> DraftSummary | None:
request = _build_draft_update_request(
repository,
article_id=article_id,
suggestion=suggestion,
override_text=override_text,
)
if request is None:
return None
base = _require_latest_draft(repository, article_id=article_id)
return update_draft(
repository,
article_id=article_id,
draft_id=base.id,
request=request,
).draft
def _build_draft_update_request(
repository: object,
*,
article_id: UUID,
suggestion: ContentReviewSuggestionSummary,
override_text: str | None,
) -> DraftUpdateRequest | None:
patch = suggestion.patch
if not isinstance(patch, dict) or not patch:
return None
base = _require_latest_draft(repository, article_id=article_id)
field = _optional_string(patch.get("field"))
if field == "meta_title":
value = override_text or _optional_string(patch.get("value"))
if value and value != (base.meta_title or ""):
return DraftUpdateRequest(meta_title=value)
return None
if field == "meta_description":
value = override_text or _optional_string(patch.get("value"))
if value and value != (base.meta_description or ""):
return DraftUpdateRequest(meta_description=value)
return None
if field == "title":
value = override_text or _optional_string(patch.get("value"))
if value and value != base.title:
return DraftUpdateRequest(title=value)
return None
if field == "body_append":
value = override_text or _optional_string(patch.get("value"))
if value:
return DraftUpdateRequest(body_markdown=f"{base.body_markdown}{value}")
return None
if field == "body_replace":
target = _optional_string(patch.get("target"))
value = override_text or _optional_string(patch.get("value"))
if target and value and target in base.body_markdown:
return DraftUpdateRequest(body_markdown=base.body_markdown.replace(target, value, 1))
return None
def _mark_review_ready(
repository: object,
*,
article_id: UUID,
event_type: str,
count: int,
) -> None:
article = repository.articles.get(article_id)
now = _now()
if article.status != ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY:
repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type=event_type,
from_status=article.status,
to_status=ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
actor_user_id=None,
payload={"issue_count": count},
created_at=now,
)
def _first_sentence_longer_than(value: str, max_words: int) -> str | None:
plain = re.sub(r"\[[^\]]+\]\([^)]+\)", "link", value)
plain = re.sub(r"^#+\s+", "", plain, flags=re.MULTILINE)
sentences = re.split(r"[.!?]\s+", plain)
for sentence in sentences:
words = [item for item in sentence.strip().split() if item]
if len(words) > max_words:
return sentence.strip()
return None
def _shorten_sentence(sentence: str, *, max_words: int) -> str:
words = [item for item in sentence.split() if item]
if len(words) <= max_words:
return sentence.strip()
return " ".join(words[: max_words - 2] + ["with", "evidence."])
def _trim(value: str, *, min_len: int, max_len: int) -> str:
cleaned = " ".join(value.split())
if len(cleaned) > max_len:
return cleaned[: max_len - 1].rstrip() + "…"
if len(cleaned) < min_len:
suffix = " SEO guidance"
while len(cleaned) < min_len:
cleaned = f"{cleaned}{suffix}"
return cleaned[:max_len]
return cleaned
def _first_duplicate(values: list[str]) -> str | None:
seen: set[str] = set()
for value in values:
key = value.strip().lower()
if key in seen:
return value.strip()
seen.add(key)
return None
def _recommended_title(*, title: str, primary_keyword: str) -> str:
if primary_keyword and primary_keyword.lower() not in title.lower():
return _trim(f"{title}: {primary_keyword} guide", min_len=30, max_len=60)
return _trim(title, min_len=30, max_len=60)
def _slugify(value: str) -> str:
normalized = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return normalized or "draft"
def _optional_string(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _now() -> datetime:
return datetime.now(UTC)
+299
View File
@@ -0,0 +1,299 @@
from __future__ import annotations
from datetime import UTC, datetime
from hashlib import sha256
from uuid import NAMESPACE_URL, UUID, uuid5
from src.application.demo_runtime import (
ensure_demo_bare_repository,
is_demo_mode,
resolve_demo_repository_path,
)
from src.domain.contracts import (
PublishingRules,
Role,
ScriptConfigVersionStatus,
WorkflowTemplateStatus,
)
from src.domain.schema import (
SEEDED_ADMIN_EMAIL,
SEEDED_EDITOR_EMAIL,
SEEDED_TARGET_SITE_SLUG,
)
SEED_TIMESTAMP = datetime(2026, 1, 1, tzinfo=UTC)
SEEDED_WORKFLOW_TEMPLATE_SLUG = "demo-article-production"
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,
)
repository_url = _repository_url_for_seed()
publishing_rules = PublishingRules(
repository_url=repository_url,
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 = f"""target: b2b_saas_blog
repository:
url: {repository_url}
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),
)
_seed_demo_workflow_template(repository, admin_id=admin_id)
def _seed_demo_workflow_template(repository: object, *, admin_id: UUID) -> None:
existing = next(
(
workflow
for workflow in repository.workflow_templates.list()
if workflow.slug == SEEDED_WORKFLOW_TEMPLATE_SLUG
),
None,
)
if existing is not None:
return
workflow = repository.workflow_templates.create(
name="Demo article production workflow",
slug=SEEDED_WORKFLOW_TEMPLATE_SLUG,
description="Active demo workflow for article intake, drafting, review, and publishing.",
status=WorkflowTemplateStatus.DRAFT,
version=1,
created_by=admin_id,
updated_by=admin_id,
created_at=SEED_TIMESTAMP,
updated_at=SEED_TIMESTAMP,
)
for stage in _demo_workflow_stages():
repository.workflow_template_stages.create(
workflow_id=workflow.id,
stable_key=stage["stable_key"],
display_name=stage["display_name"],
description=stage["description"],
position=stage["position"],
owner_role=stage["owner_role"],
runner_profile_key=stage["runner_profile_key"],
required_inputs=stage["required_inputs"],
expected_outputs=stage["expected_outputs"],
acceptance_criteria=stage["acceptance_criteria"],
requires_human_approval=stage["requires_human_approval"],
retry_policy=stage["retry_policy"],
parts=stage["parts"],
updated_by=admin_id,
created_at=SEED_TIMESTAMP,
updated_at=SEED_TIMESTAMP,
)
repository.workflow_templates.activate(
workflow_id=workflow.id,
updated_by=admin_id,
activated_at=SEED_TIMESTAMP,
)
def _demo_workflow_stages() -> list[dict[str, object]]:
return [
{
"stable_key": "intake-boundary-questions",
"display_name": "Boundary question intake",
"description": "Collect required editorial context before plan generation.",
"position": 1,
"owner_role": Role.EDITOR,
"runner_profile_key": "boundary-question-agent-v1",
"required_inputs": ["article_brief", "target_site"],
"expected_outputs": ["answered_boundary_questions"],
"acceptance_criteria": ["All required boundary questions are answered."],
"requires_human_approval": True,
"retry_policy": {"max_attempts": 1, "backoff_seconds": 0},
"parts": [],
},
{
"stable_key": "plan-review",
"display_name": "Plan review",
"description": "Generate and approve the article plan before research.",
"position": 2,
"owner_role": Role.EDITOR,
"runner_profile_key": "plan-generation-agent-v1",
"required_inputs": ["answered_boundary_questions"],
"expected_outputs": ["approved_plan"],
"acceptance_criteria": ["Plan is approved by the editor."],
"requires_human_approval": True,
"retry_policy": {"max_attempts": 2, "backoff_seconds": 30},
"parts": [],
},
{
"stable_key": "research-evidence",
"display_name": "Research and evidence",
"description": "Run research and approve evidence before production starts.",
"position": 3,
"owner_role": Role.EDITOR,
"runner_profile_key": "research-agent-v1",
"required_inputs": ["approved_plan"],
"expected_outputs": ["approved_evidence_matrix"],
"acceptance_criteria": ["Evidence matrix is ready and approved."],
"requires_human_approval": True,
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
"parts": [],
},
{
"stable_key": "parallel-production",
"display_name": "Parallel production",
"description": "Create section scaffolds and draft-ready assets in parallel.",
"position": 4,
"owner_role": Role.EDITOR,
"runner_profile_key": "section-scaffold-agent-v1",
"required_inputs": ["approved_evidence_matrix"],
"expected_outputs": ["section_scaffolds", "asset_specs"],
"acceptance_criteria": ["All production jobs have succeeded or been retried."],
"requires_human_approval": False,
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
"parts": [],
},
{
"stable_key": "draft-assembly",
"display_name": "Draft assembly",
"description": "Assemble a draft from the approved plan and production artifacts.",
"position": 5,
"owner_role": Role.EDITOR,
"runner_profile_key": "draft-assembly-agent-v1",
"required_inputs": ["section_scaffolds", "approved_assets"],
"expected_outputs": ["article_draft"],
"acceptance_criteria": ["Draft is assembled for review."],
"requires_human_approval": False,
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
"parts": [],
},
{
"stable_key": "seo-language-review",
"display_name": "SEO and language review",
"description": "Run automated quality reviews and resolve required suggestions.",
"position": 6,
"owner_role": Role.EDITOR,
"runner_profile_key": "review-agent-v1",
"required_inputs": ["article_draft"],
"expected_outputs": ["resolved_review_suggestions"],
"acceptance_criteria": ["Required SEO and language suggestions are resolved."],
"requires_human_approval": True,
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
"parts": [],
},
{
"stable_key": "final-publishing",
"display_name": "Final approval and publishing",
"description": "Approve final draft, dry-run the bundle, and create the publish commit.",
"position": 7,
"owner_role": Role.EDITOR,
"runner_profile_key": "publishing-agent-v1",
"required_inputs": ["resolved_review_suggestions", "approved_assets"],
"expected_outputs": ["publish_commit"],
"acceptance_criteria": ["Publish dry-run passes and commit is created."],
"requires_human_approval": True,
"retry_policy": {"max_attempts": 1, "backoff_seconds": 0},
"parts": [],
},
]
def _repository_url_for_seed() -> str:
if not is_demo_mode():
return "git@github.com:example/site.git"
demo_repo_path = resolve_demo_repository_path()
ensure_demo_bare_repository(demo_repo_path)
return str(demo_repo_path)
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()
+311
View File
@@ -0,0 +1,311 @@
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,
ScriptConfigVersionAuditEventListResponse,
ScriptConfigVersionAuditEventSummary,
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 list_script_config_audit_events(
repository: object,
site_id: UUID,
) -> ScriptConfigVersionAuditEventListResponse:
repository.target_sites.get_by_id(site_id)
version_rows = repository.script_config_versions.list_for_site(site_id)
version_by_id = {str(row["id"]): row for row in version_rows}
events = repository.script_config_version_events.list_for_site(site_id)
summaries: list[ScriptConfigVersionAuditEventSummary] = []
for event in events:
version_row = version_by_id.get(str(event["version_id"]))
payload = event["payload"] if isinstance(event["payload"], dict) else {}
summaries.append(
ScriptConfigVersionAuditEventSummary(
id=event["id"],
target_site_id=event["target_site_id"],
version_id=event["version_id"],
event_type=event["event_type"],
actor_user_id=event["actor_user_id"],
payload=payload,
created_at=event["created_at"],
version=(
int(version_row["version"])
if version_row is not None and version_row.get("version") is not None
else None
),
diff=(
version_row["diff"]
if version_row is not None and isinstance(version_row.get("diff"), dict)
else {}
),
rollback_target_version_id=(
version_row["rollback_target_version_id"]
if version_row is not None
else None
),
)
)
return ScriptConfigVersionAuditEventListResponse(events=summaries)
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)
@@ -0,0 +1,372 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import UUID
from src.domain.contracts import (
CurrentUser,
Role,
WorkflowStageCreateRequest,
WorkflowStageResponse,
WorkflowStageUpdateRequest,
WorkflowTemplateAuditEventListResponse,
WorkflowTemplateCreateRequest,
WorkflowTemplateListResponse,
WorkflowTemplateResponse,
WorkflowTemplateStatus,
WorkflowTemplateSummary,
WorkflowTemplateUpdateRequest,
)
_TEMPLATE_CREATED_EVENT_TYPE = "WORKFLOW_TEMPLATE_CREATED"
_TEMPLATE_UPDATED_EVENT_TYPE = "WORKFLOW_TEMPLATE_UPDATED"
_STAGE_CREATED_EVENT_TYPE = "WORKFLOW_STAGE_CREATED"
_STAGE_UPDATED_EVENT_TYPE = "WORKFLOW_STAGE_UPDATED"
_STAGE_DELETED_EVENT_TYPE = "WORKFLOW_STAGE_DELETED"
_STAGES_REORDERED_EVENT_TYPE = "WORKFLOW_STAGES_REORDERED"
_TEMPLATE_ACTIVATED_EVENT_TYPE = "WORKFLOW_TEMPLATE_ACTIVATED"
_TEMPLATE_ARCHIVED_EVENT_TYPE = "WORKFLOW_TEMPLATE_ARCHIVED"
def create_workflow_template(
repository: object,
*,
request: WorkflowTemplateCreateRequest,
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
now = _now()
workflow = repository.workflow_templates.create(
name=request.name,
slug=request.slug,
description=request.description,
status=WorkflowTemplateStatus.DRAFT,
version=1,
created_by=current_user.id,
updated_by=current_user.id,
created_at=now,
updated_at=now,
)
_create_event(
repository,
workflow_id=workflow.id,
event_type=_TEMPLATE_CREATED_EVENT_TYPE,
current_user=current_user,
payload={"slug": workflow.slug, "version": workflow.version},
created_at=now,
)
return WorkflowTemplateResponse(workflow=workflow)
def list_workflow_templates(
repository: object,
*,
current_user: CurrentUser,
) -> WorkflowTemplateListResponse:
if current_user.role == Role.ADMIN:
workflows = repository.workflow_templates.list()
else:
workflows = repository.workflow_templates.list_active()
return WorkflowTemplateListResponse(workflows=workflows)
def get_workflow_template(
repository: object,
*,
workflow_id: UUID,
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
workflow = repository.workflow_templates.get(workflow_id)
if current_user.role != Role.ADMIN and workflow.status != WorkflowTemplateStatus.ACTIVE:
raise LookupError(f"Workflow template not found: {workflow_id}")
return WorkflowTemplateResponse(workflow=workflow)
def update_workflow_template(
repository: object,
*,
workflow_id: UUID,
request: WorkflowTemplateUpdateRequest,
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
existing = repository.workflow_templates.get(workflow_id)
_require_draft(existing)
changes = request.model_dump(exclude_unset=True)
now = _now()
workflow = repository.workflow_templates.update(
workflow_id=workflow_id,
name=changes.get("name", existing.name),
slug=changes.get("slug", existing.slug),
description=changes.get("description", existing.description),
updated_by=current_user.id,
updated_at=now,
)
_create_event(
repository,
workflow_id=workflow.id,
event_type=_TEMPLATE_UPDATED_EVENT_TYPE,
current_user=current_user,
payload={"changed_fields": sorted(changes.keys())},
created_at=now,
)
return WorkflowTemplateResponse(workflow=workflow)
def add_workflow_stage(
repository: object,
*,
workflow_id: UUID,
request: WorkflowStageCreateRequest,
current_user: CurrentUser,
) -> WorkflowStageResponse:
workflow = repository.workflow_templates.get(workflow_id)
_require_draft(workflow)
now = _now()
stage = repository.workflow_template_stages.create(
workflow_id=workflow_id,
stable_key=request.stable_key,
display_name=request.display_name,
description=request.description,
position=request.position,
owner_role=request.owner_role,
runner_profile_key=request.runner_profile_key,
required_inputs=request.required_inputs,
expected_outputs=request.expected_outputs,
acceptance_criteria=request.acceptance_criteria,
requires_human_approval=request.requires_human_approval,
retry_policy=request.retry_policy,
parts=_parts_json(request.parts),
updated_by=current_user.id,
created_at=now,
updated_at=now,
)
_create_event(
repository,
workflow_id=workflow_id,
event_type=_STAGE_CREATED_EVENT_TYPE,
current_user=current_user,
payload={
"stage_id": str(stage.id),
"stable_key": stage.stable_key,
"position": stage.position,
},
created_at=now,
)
return WorkflowStageResponse(stage=stage)
def update_workflow_stage(
repository: object,
*,
workflow_id: UUID,
stage_id: UUID,
request: WorkflowStageUpdateRequest,
current_user: CurrentUser,
) -> WorkflowStageResponse:
workflow = repository.workflow_templates.get(workflow_id)
_require_draft(workflow)
existing = repository.workflow_template_stages.get(
workflow_id=workflow_id,
stage_id=stage_id,
)
changes = request.model_dump(exclude_unset=True)
now = _now()
stage = repository.workflow_template_stages.update(
workflow_id=workflow_id,
stage_id=stage_id,
stable_key=changes.get("stable_key", existing.stable_key),
display_name=changes.get("display_name", existing.display_name),
description=changes.get("description", existing.description),
position=changes.get("position", existing.position),
owner_role=request.owner_role or existing.owner_role,
runner_profile_key=changes.get(
"runner_profile_key",
existing.runner_profile_key,
),
required_inputs=changes.get("required_inputs", existing.required_inputs),
expected_outputs=changes.get("expected_outputs", existing.expected_outputs),
acceptance_criteria=changes.get(
"acceptance_criteria",
existing.acceptance_criteria,
),
requires_human_approval=changes.get(
"requires_human_approval",
existing.requires_human_approval,
),
retry_policy=changes.get("retry_policy", existing.retry_policy),
parts=(
_parts_json(request.parts)
if request.parts is not None
else _parts_json(existing.parts)
),
updated_by=current_user.id,
updated_at=now,
)
_create_event(
repository,
workflow_id=workflow_id,
event_type=_STAGE_UPDATED_EVENT_TYPE,
current_user=current_user,
payload={"stage_id": str(stage_id), "changed_fields": sorted(changes.keys())},
created_at=now,
)
return WorkflowStageResponse(stage=stage)
def delete_workflow_stage(
repository: object,
*,
workflow_id: UUID,
stage_id: UUID,
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
workflow = repository.workflow_templates.get(workflow_id)
_require_draft(workflow)
existing = repository.workflow_template_stages.get(
workflow_id=workflow_id,
stage_id=stage_id,
)
now = _now()
repository.workflow_template_stages.delete(
workflow_id=workflow_id,
stage_id=stage_id,
updated_by=current_user.id,
updated_at=now,
)
_create_event(
repository,
workflow_id=workflow_id,
event_type=_STAGE_DELETED_EVENT_TYPE,
current_user=current_user,
payload={"stage_id": str(stage_id), "stable_key": existing.stable_key},
created_at=now,
)
return WorkflowTemplateResponse(workflow=repository.workflow_templates.get(workflow_id))
def reorder_workflow_stages(
repository: object,
*,
workflow_id: UUID,
stage_ids: list[UUID],
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
workflow = repository.workflow_templates.get(workflow_id)
_require_draft(workflow)
now = _now()
repository.workflow_template_stages.reorder(
workflow_id=workflow_id,
stage_ids=stage_ids,
updated_by=current_user.id,
updated_at=now,
)
_create_event(
repository,
workflow_id=workflow_id,
event_type=_STAGES_REORDERED_EVENT_TYPE,
current_user=current_user,
payload={"stage_ids": [str(stage_id) for stage_id in stage_ids]},
created_at=now,
)
return WorkflowTemplateResponse(workflow=repository.workflow_templates.get(workflow_id))
def activate_workflow_template(
repository: object,
*,
workflow_id: UUID,
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
workflow = repository.workflow_templates.get(workflow_id)
_require_draft(workflow)
now = _now()
activated = repository.workflow_templates.activate(
workflow_id=workflow_id,
updated_by=current_user.id,
activated_at=now,
)
_create_event(
repository,
workflow_id=workflow_id,
event_type=_TEMPLATE_ACTIVATED_EVENT_TYPE,
current_user=current_user,
payload={"version": activated.version},
created_at=now,
)
return WorkflowTemplateResponse(workflow=activated)
def archive_workflow_template(
repository: object,
*,
workflow_id: UUID,
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
repository.workflow_templates.get(workflow_id)
now = _now()
archived = repository.workflow_templates.archive(
workflow_id=workflow_id,
updated_by=current_user.id,
archived_at=now,
)
_create_event(
repository,
workflow_id=workflow_id,
event_type=_TEMPLATE_ARCHIVED_EVENT_TYPE,
current_user=current_user,
payload={"version": archived.version},
created_at=now,
)
return WorkflowTemplateResponse(workflow=archived)
def list_workflow_template_audit_events(
repository: object,
*,
workflow_id: UUID,
) -> WorkflowTemplateAuditEventListResponse:
repository.workflow_templates.get(workflow_id)
return WorkflowTemplateAuditEventListResponse(
events=repository.workflow_template_events.list_for_workflow(workflow_id)
)
def _create_event(
repository: object,
*,
workflow_id: UUID,
event_type: str,
current_user: CurrentUser,
payload: dict[str, object],
created_at: datetime,
) -> None:
repository.workflow_template_events.create(
workflow_id=workflow_id,
event_type=event_type,
actor_user_id=current_user.id,
payload=payload,
created_at=created_at,
)
def _require_draft(workflow: WorkflowTemplateSummary) -> None:
if workflow.status != WorkflowTemplateStatus.DRAFT:
raise ValueError("Only draft workflow templates can be mutated.")
def _parts_json(parts: list[object]) -> list[dict[str, object]]:
return [
part.model_dump(mode="json") if hasattr(part, "model_dump") else dict(part)
for part in parts
]
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,253 @@
from .enums import (
AgentJobErrorCategory,
AgentJobStatus,
AgentJobType,
ArticleWorkflowStatus,
ContentReviewKind,
AssetStatus,
AssetType,
ClaimRiskLevel,
ClaimSupportStatus,
PlanReviewStatus,
PublishingStatus,
ReviewStatus,
ReviewSuggestionStatus,
ReviewType,
Role,
ScriptConfigVersionStatus,
WorkflowTemplateStatus,
)
from .models import (
AgentJobOutput,
AgentJobListResponse,
AgentJobResponse,
AgentJobSummary,
AgentJobTestCodexRequest,
ArticleCreateRequest,
ArticleCreateResponse,
ArticleDetailResponse,
ArticleListResponse,
ArticleSummary,
BoundaryQuestionListResponse,
BoundaryQuestionResponse,
BoundaryQuestionSummary,
BoundaryQuestionUpdateRequest,
WorkflowEventSummary,
ObservabilityTimelineEventSummary,
WorkflowStageCreateRequest,
WorkflowStagePart,
WorkflowStageReorderRequest,
WorkflowStageResponse,
WorkflowStageSummary,
WorkflowStageUpdateRequest,
WorkflowTemplateAuditEventListResponse,
WorkflowTemplateAuditEventSummary,
WorkflowTemplateCreateRequest,
WorkflowTemplateListResponse,
WorkflowTemplateResponse,
WorkflowTemplateSnapshot,
WorkflowTemplateStageSnapshot,
WorkflowTemplateSummary,
WorkflowTemplateUpdateRequest,
AssetGenerateSpecsResponse,
AssetListResponse,
AssetResponse,
AssetRevisionSummary,
AssetSummary,
AssetUpdateRequest,
AssetUploadRequest,
AssetUploadResponse,
ClaimSummary,
CurrentUser,
CurrentUserResponse,
DraftAssembleResponse,
DraftFaqItem,
DraftListResponse,
DraftResponse,
DraftSummary,
DraftUpdateRequest,
ContentReviewIssueSummary,
ContentReviewReportSummary,
ContentReviewSuggestionSummary,
EvidenceSummary,
EvidenceCreateRequest,
EvidenceMatrixResponse,
EvidenceResponse,
EvidenceUpdateRequest,
FinalApprovalChecklistItemSummary,
FinalApprovalChecklistResponse,
FinalApprovalPublishingSettings,
FinalApprovalRequest,
FinalApprovalResponse,
PlanSectionSummary,
PlanSectionEditRequest,
PlanListResponse,
PlanResponse,
PlanRevisionRequest,
PlanSummary,
PlanUpdateRequest,
PublishCommitSummary,
PublishCommitCreateResponse,
PublishCommitListResponse,
PublishingDryRunResponse,
PublishingStatusResponse,
PublishingRules,
ResearchArtifactManifestSummary,
ResearchArtifactSummary,
ResearchListResponse,
ResearchStartResponse,
ReviewActionResponse,
ReviewSuggestionActionResponse,
ReviewSuggestionEditRequest,
ReviewSummary,
SeoReviewRunResponse,
SeoReviewReportResponse,
LanguageReviewRunResponse,
LanguageReviewReportResponse,
FinalRevisionRequest,
FinalRevisionRequestResponse,
FinalReviewIssueSummary,
FinalReviewIssuesResponse,
RunnerFileRef,
ScriptConfigVersionAuditEventListResponse,
ScriptConfigVersionAuditEventSummary,
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",
"ObservabilityTimelineEventSummary",
"WorkflowStageCreateRequest",
"WorkflowStagePart",
"WorkflowStageReorderRequest",
"WorkflowStageResponse",
"WorkflowStageSummary",
"WorkflowStageUpdateRequest",
"WorkflowTemplateAuditEventListResponse",
"WorkflowTemplateAuditEventSummary",
"WorkflowTemplateCreateRequest",
"WorkflowTemplateListResponse",
"WorkflowTemplateResponse",
"WorkflowTemplateSnapshot",
"WorkflowTemplateStageSnapshot",
"WorkflowTemplateStatus",
"WorkflowTemplateSummary",
"WorkflowTemplateUpdateRequest",
"AssetGenerateSpecsResponse",
"AssetListResponse",
"AssetResponse",
"AssetRevisionSummary",
"ArticleWorkflowStatus",
"AssetStatus",
"AssetSummary",
"AssetType",
"AssetUpdateRequest",
"AssetUploadRequest",
"AssetUploadResponse",
"ClaimRiskLevel",
"ClaimSummary",
"ClaimSupportStatus",
"CurrentUser",
"CurrentUserResponse",
"ContentReviewIssueSummary",
"ContentReviewKind",
"ContentReviewReportSummary",
"ContentReviewSuggestionSummary",
"DraftAssembleResponse",
"DraftFaqItem",
"DraftListResponse",
"DraftResponse",
"DraftSummary",
"DraftUpdateRequest",
"EvidenceSummary",
"EvidenceCreateRequest",
"EvidenceMatrixResponse",
"EvidenceResponse",
"EvidenceUpdateRequest",
"FinalApprovalChecklistItemSummary",
"FinalApprovalChecklistResponse",
"FinalApprovalPublishingSettings",
"FinalApprovalRequest",
"FinalApprovalResponse",
"FinalRevisionRequest",
"FinalRevisionRequestResponse",
"FinalReviewIssueSummary",
"FinalReviewIssuesResponse",
"LanguageReviewRunResponse",
"LanguageReviewReportResponse",
"PlanReviewStatus",
"PlanListResponse",
"PlanResponse",
"PlanRevisionRequest",
"PlanSectionEditRequest",
"PlanSectionSummary",
"PlanSummary",
"PlanUpdateRequest",
"PublishCommitCreateResponse",
"PublishCommitListResponse",
"PublishCommitSummary",
"PublishingDryRunResponse",
"PublishingRules",
"PublishingStatusResponse",
"PublishingStatus",
"ResearchArtifactManifestSummary",
"ResearchArtifactSummary",
"ResearchListResponse",
"ResearchStartResponse",
"ReviewActionResponse",
"ReviewStatus",
"ReviewSuggestionActionResponse",
"ReviewSuggestionEditRequest",
"ReviewSuggestionStatus",
"ReviewSummary",
"ReviewType",
"Role",
"RunnerFileRef",
"ScriptConfigVersionAuditEventListResponse",
"ScriptConfigVersionAuditEventSummary",
"SeoReviewRunResponse",
"SeoReviewReportResponse",
"ScriptConfigVersionCreateRequest",
"ScriptConfigVersionListResponse",
"ScriptConfigVersionResponse",
"ScriptConfigVersionStatus",
"ScriptConfigVersionSummary",
"TargetSiteConfig",
"TargetSiteConfigCreateRequest",
"TargetSiteConfigResponse",
"TargetSiteConfigUpdateRequest",
"UserSummary",
"next_workflow_statuses",
]
+147
View File
@@ -0,0 +1,147 @@
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"
FLOWCHART = "flowchart"
COMPARISON_MATRIX = "comparison_matrix"
ARCHITECTURE_DIAGRAM = "architecture_diagram"
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 ContentReviewKind(str, Enum):
SEO = "SEO"
LANGUAGE = "LANGUAGE"
class ReviewSuggestionStatus(str, Enum):
PENDING = "PENDING"
ACCEPTED = "ACCEPTED"
REJECTED = "REJECTED"
EDITED = "EDITED"
class ScriptConfigVersionStatus(str, Enum):
DRAFT = "DRAFT"
ACTIVE = "ACTIVE"
DEPRECATED = "DEPRECATED"
class WorkflowTemplateStatus(str, Enum):
DRAFT = "DRAFT"
ACTIVE = "ACTIVE"
ARCHIVED = "ARCHIVED"
+893
View File
@@ -0,0 +1,893 @@
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,
ContentReviewKind,
AssetStatus,
AssetType,
ClaimRiskLevel,
ClaimSupportStatus,
PlanReviewStatus,
PublishingStatus,
ReviewStatus,
ReviewSuggestionStatus,
ReviewType,
Role,
ScriptConfigVersionStatus,
WorkflowTemplateStatus,
)
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 ScriptConfigVersionAuditEventSummary(ContractModel):
id: UUID
target_site_id: UUID
version_id: UUID
event_type: str = Field(min_length=1)
actor_user_id: UUID | None = None
payload: JsonObject = Field(default_factory=dict)
created_at: datetime
version: int | None = Field(default=None, ge=1)
diff: JsonObject = Field(default_factory=dict)
rollback_target_version_id: UUID | None = None
class ScriptConfigVersionAuditEventListResponse(ContractModel):
events: list[ScriptConfigVersionAuditEventSummary] = Field(default_factory=list)
class WorkflowStagePart(ContractModel):
key: str = Field(min_length=1)
type: str = Field(min_length=1)
title: str = Field(min_length=1)
payload: JsonObject = Field(default_factory=dict)
acceptance_criteria: list[str] = Field(default_factory=list)
class WorkflowStageSummary(ContractModel):
id: UUID
workflow_id: UUID
stable_key: str = Field(min_length=1)
display_name: str = Field(min_length=1)
description: str = Field(min_length=1)
position: int = Field(ge=1)
owner_role: Role
runner_profile_key: str = Field(min_length=1)
required_inputs: list[str] = Field(default_factory=list)
expected_outputs: list[str] = Field(default_factory=list)
acceptance_criteria: list[str] = Field(default_factory=list)
requires_human_approval: bool = False
retry_policy: JsonObject = Field(default_factory=dict)
parts: list[WorkflowStagePart] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class WorkflowTemplateSummary(ContractModel):
id: UUID
name: str = Field(min_length=1)
slug: str = Field(min_length=1)
description: str = Field(min_length=1)
status: WorkflowTemplateStatus
version: int = Field(ge=1)
created_by: UUID
updated_by: UUID | None = None
created_at: datetime
updated_at: datetime
activated_at: datetime | None = None
archived_at: datetime | None = None
stages: list[WorkflowStageSummary] = Field(default_factory=list)
class WorkflowTemplateCreateRequest(ContractModel):
name: str = Field(min_length=1)
slug: str = Field(min_length=1)
description: str = Field(min_length=1)
class WorkflowTemplateUpdateRequest(ContractModel):
name: str | None = Field(default=None, min_length=1)
slug: str | None = Field(default=None, min_length=1)
description: str | None = Field(default=None, min_length=1)
class WorkflowStageCreateRequest(ContractModel):
stable_key: str = Field(min_length=1)
display_name: str = Field(min_length=1)
description: str = Field(min_length=1)
position: int | None = Field(default=None, ge=1)
owner_role: Role
runner_profile_key: str = Field(min_length=1)
required_inputs: list[str] = Field(default_factory=list)
expected_outputs: list[str] = Field(default_factory=list)
acceptance_criteria: list[str] = Field(default_factory=list)
requires_human_approval: bool = False
retry_policy: JsonObject = Field(default_factory=dict)
parts: list[WorkflowStagePart] = Field(default_factory=list)
class WorkflowStageUpdateRequest(ContractModel):
stable_key: str | None = Field(default=None, min_length=1)
display_name: str | None = Field(default=None, min_length=1)
description: str | None = Field(default=None, min_length=1)
position: int | None = Field(default=None, ge=1)
owner_role: Role | None = None
runner_profile_key: str | None = Field(default=None, min_length=1)
required_inputs: list[str] | None = None
expected_outputs: list[str] | None = None
acceptance_criteria: list[str] | None = None
requires_human_approval: bool | None = None
retry_policy: JsonObject | None = None
parts: list[WorkflowStagePart] | None = None
class WorkflowStageReorderRequest(ContractModel):
stage_ids: list[UUID] = Field(min_length=1)
class WorkflowTemplateResponse(ContractModel):
workflow: WorkflowTemplateSummary
class WorkflowTemplateListResponse(ContractModel):
workflows: list[WorkflowTemplateSummary] = Field(default_factory=list)
class WorkflowStageResponse(ContractModel):
stage: WorkflowStageSummary
class WorkflowTemplateAuditEventSummary(ContractModel):
id: UUID
workflow_id: UUID
event_type: str = Field(min_length=1)
actor_user_id: UUID | None = None
payload: JsonObject = Field(default_factory=dict)
created_at: datetime
class WorkflowTemplateAuditEventListResponse(ContractModel):
events: list[WorkflowTemplateAuditEventSummary] = Field(default_factory=list)
class WorkflowTemplateStageSnapshot(ContractModel):
id: UUID
stable_key: str = Field(min_length=1)
display_name: str = Field(min_length=1)
position: int = Field(ge=1)
owner_role: Role
runner_profile_key: str = Field(min_length=1)
requires_human_approval: bool = False
class WorkflowTemplateSnapshot(ContractModel):
id: UUID
name: str = Field(min_length=1)
slug: str = Field(min_length=1)
version: int = Field(ge=1)
stage_summary: list[WorkflowTemplateStageSnapshot] = Field(default_factory=list)
class ArticleCreateRequest(ContractModel):
target_site_id: UUID
workflow_template_id: UUID | None = None
brief_description: str = Field(min_length=1)
working_title: str | None = None
language: str = Field(default="en", min_length=2)
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
workflow_template_id: UUID | None = None
workflow_template_version: int | None = Field(default=None, ge=1)
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 ObservabilityTimelineEventSummary(ContractModel):
id: UUID
article_id: UUID
entry_type: str = Field(min_length=1)
source: str = Field(min_length=1)
event_type: str = Field(min_length=1)
from_status: ArticleWorkflowStatus | None = None
to_status: ArticleWorkflowStatus | None = None
actor_user_id: UUID | None = None
job_id: UUID | None = None
job_type: AgentJobType | None = None
job_status: AgentJobStatus | None = None
retry_eligible: bool = False
cancel_eligible: bool = False
safe_failure_summary: str | 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
review_status: str = Field(default="PENDING", min_length=1)
class EvidenceUpdateRequest(ContractModel):
review_status: str | None = Field(default=None, pattern="^(PENDING|APPROVED|REJECTED)$")
class EvidenceCreateRequest(ContractModel):
source_title: str = Field(min_length=1)
source_url: str = Field(min_length=1)
source_type: str = Field(min_length=1)
summary: str = Field(min_length=1)
source_quality_score: float = Field(ge=0, le=1, default=0.7)
section_id: UUID | None = None
claim_text: str | None = None
support_status: ClaimSupportStatus = ClaimSupportStatus.SUPPORTED
risk_level: ClaimRiskLevel = ClaimRiskLevel.LOW
class EvidenceResponse(ContractModel):
evidence: EvidenceSummary
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 EvidenceMatrixResponse(ContractModel):
article: ArticleSummary
evidence: list[EvidenceSummary] = Field(default_factory=list)
claims: list[ClaimSummary] = Field(default_factory=list)
insufficient_evidence_reasons: list[str] = Field(default_factory=list)
class DraftFaqItem(ContractModel):
question: str = Field(min_length=1)
answer: str = Field(min_length=1)
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
body_markdown: str = ""
faq_items: list[DraftFaqItem] = Field(default_factory=list)
visual_placeholders: list[str] = Field(default_factory=list)
evidence_references: list[UUID] = Field(default_factory=list)
unsupported_claim_warnings: list[str] = Field(default_factory=list)
based_on_draft_id: UUID | None = None
status: ArticleWorkflowStatus
created_at: datetime
updated_at: datetime
class AssetRevisionSummary(ContractModel):
id: UUID
asset_id: UUID
article_id: UUID
revision_index: int = Field(ge=1)
action: str = Field(min_length=1)
actor_user_id: UUID | None = None
payload: JsonObject = Field(default_factory=dict)
created_at: datetime
class AssetSummary(ContractModel):
id: UUID
article_id: UUID
section_id: UUID | None = None
asset_type: AssetType
title: str = Field(min_length=1)
prompt: str | None = None
object_key: str | None = None
file_url: str | None = None
alt_text: str | None = None
caption: str | None = None
status: AssetStatus
history: list[AssetRevisionSummary] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
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 PublishingDryRunResponse(ContractModel):
article: ArticleSummary
publish_commit: PublishCommitSummary
content_shape_valid: bool
validation_label: str = Field(min_length=1)
errors: list[str] = Field(default_factory=list)
class PublishCommitCreateResponse(ContractModel):
article: ArticleSummary
publish_commit: PublishCommitSummary
class PublishingStatusResponse(ContractModel):
article: ArticleSummary
latest_dry_run: PublishCommitSummary | None = None
latest_publish_commit: PublishCommitSummary | None = None
validation_label: str = Field(min_length=1)
class PublishCommitListResponse(ContractModel):
commits: list[PublishCommitSummary] = Field(default_factory=list)
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)
payload: JsonObject = Field(default_factory=dict)
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
retry_eligible: bool = False
retry_block_reason: str | None = None
cancel_eligible: bool = False
safe_failure_summary: str | 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 DraftAssembleResponse(ContractModel):
draft: DraftSummary
class DraftListResponse(ContractModel):
drafts: list[DraftSummary] = Field(default_factory=list)
class DraftResponse(ContractModel):
draft: DraftSummary
class DraftUpdateRequest(ContractModel):
title: str | None = Field(default=None, min_length=1)
meta_title: str | None = None
meta_description: str | None = None
body_markdown: str | None = None
faq_items: list[DraftFaqItem] | None = None
visual_placeholders: list[str] | None = None
evidence_references: list[UUID] | None = None
unsupported_claim_warnings: list[str] | None = None
class ContentReviewIssueSummary(ContractModel):
id: str = Field(min_length=1)
suggestion_id: UUID
severity: str = Field(min_length=1)
location: str = Field(min_length=1)
message: str = Field(min_length=1)
suggested_fix: str | None = None
suggested_rewrite: str | None = None
status: ReviewSuggestionStatus
class ContentReviewReportSummary(ContractModel):
id: UUID
article_id: UUID
review_kind: ContentReviewKind
draft_id: UUID
score: int = Field(ge=0, le=100)
issues: list[ContentReviewIssueSummary] = Field(default_factory=list)
recommended_slug: str = Field(min_length=1)
recommended_title: str = Field(min_length=1)
schema_json: JsonObject = Field(default_factory=dict)
rules_snapshot: JsonObject = Field(default_factory=dict)
created_at: datetime
class ContentReviewSuggestionSummary(ContractModel):
id: UUID
article_id: UUID
review_kind: ContentReviewKind
report_id: UUID
suggestion_key: str = Field(min_length=1)
severity: str = Field(min_length=1)
location: str = Field(min_length=1)
message: str = Field(min_length=1)
suggested_fix: str | None = None
suggested_rewrite: str | None = None
patch: JsonObject = Field(default_factory=dict)
status: ReviewSuggestionStatus
applied_text: str | None = None
created_at: datetime
updated_at: datetime
class SeoReviewRunResponse(ContractModel):
report: ContentReviewReportSummary
class SeoReviewReportResponse(ContractModel):
report: ContentReviewReportSummary
class LanguageReviewRunResponse(ContractModel):
report: ContentReviewReportSummary
class LanguageReviewReportResponse(ContractModel):
report: ContentReviewReportSummary
class ReviewSuggestionActionResponse(ContractModel):
suggestion: ContentReviewSuggestionSummary
draft: DraftSummary | None = None
class ReviewSuggestionEditRequest(ContractModel):
applied_text: str = Field(min_length=1)
class FinalReviewIssueSummary(ContractModel):
review_kind: ContentReviewKind
suggestion_id: UUID
severity: str = Field(min_length=1)
location: str = Field(min_length=1)
message: str = Field(min_length=1)
status: ReviewSuggestionStatus
class FinalReviewIssuesResponse(ContractModel):
article_id: UUID
unresolved_count: int = Field(ge=0)
unresolved_issues: list[FinalReviewIssueSummary] = Field(default_factory=list)
class FinalApprovalPublishingSettings(ContractModel):
content_path: str | None = None
author: str | None = None
publishing_mode: str | None = None
frontmatter: JsonObject | None = None
class FinalApprovalRequest(ContractModel):
draft_version: int | None = Field(default=None, ge=1)
publishing_settings: FinalApprovalPublishingSettings | None = None
class FinalRevisionRequest(ContractModel):
reason: str | None = None
draft_version: int | None = Field(default=None, ge=1)
class FinalApprovalChecklistItemSummary(ContractModel):
key: str = Field(min_length=1)
title: str = Field(min_length=1)
completed: bool
blocker: str | None = None
class FinalApprovalChecklistResponse(ContractModel):
article_id: UUID
draft_version: int | None = Field(default=None, ge=1)
requested_draft_version: int | None = Field(default=None, ge=1)
can_approve: bool
completed_items: list[str] = Field(default_factory=list)
blockers: list[str] = Field(default_factory=list)
items: list[FinalApprovalChecklistItemSummary] = Field(default_factory=list)
class FinalApprovalResponse(ContractModel):
article: ArticleSummary
checklist: FinalApprovalChecklistResponse
class FinalRevisionRequestResponse(ContractModel):
article: ArticleSummary
checklist: FinalApprovalChecklistResponse
class AssetGenerateSpecsResponse(ContractModel):
assets: list[AssetSummary] = Field(default_factory=list)
class AssetListResponse(ContractModel):
assets: list[AssetSummary] = Field(default_factory=list)
class AssetResponse(ContractModel):
asset: AssetSummary
class AssetUpdateRequest(ContractModel):
title: str | None = Field(default=None, min_length=1)
prompt: str | None = None
alt_text: str | None = None
caption: str | None = None
section_id: UUID | None = None
status: AssetStatus | None = None
class AssetUploadRequest(ContractModel):
filename: str = Field(min_length=1)
content_base64: str = Field(min_length=1)
content_type: str | None = None
class AssetUploadResponse(ContractModel):
asset: AssetSummary
class ArticleDetailResponse(ContractModel):
article: ArticleSummary
target_site: TargetSiteConfig | None = None
workflow_template_snapshot: WorkflowTemplateSnapshot | None = None
workflow_events: list[WorkflowEventSummary] = Field(default_factory=list)
boundary_questions: list[BoundaryQuestionSummary] = Field(default_factory=list)
plan: PlanSummary | None = None
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)
timeline: list[ObservabilityTimelineEventSummary] = Field(default_factory=list)
research_manifests: list[ResearchArtifactManifestSummary] = Field(
default_factory=list
)
publish_commit: PublishCommitSummary | None = None
@@ -0,0 +1,271 @@
from __future__ import annotations
from enum import Enum
from typing import Any
from pydantic import BaseModel, TypeAdapter
from .enums import (
AgentJobErrorCategory,
AgentJobStatus,
AgentJobType,
ArticleWorkflowStatus,
ContentReviewKind,
AssetStatus,
AssetType,
ClaimRiskLevel,
ClaimSupportStatus,
PlanReviewStatus,
PublishingStatus,
ReviewStatus,
ReviewSuggestionStatus,
ReviewType,
Role,
ScriptConfigVersionStatus,
WorkflowTemplateStatus,
)
from .models import (
AgentJobOutput,
AgentJobListResponse,
AgentJobResponse,
AgentJobSummary,
AgentJobTestCodexRequest,
ArticleCreateRequest,
ArticleCreateResponse,
ArticleDetailResponse,
ArticleListResponse,
ArticleSummary,
BoundaryQuestionListResponse,
BoundaryQuestionResponse,
BoundaryQuestionSummary,
BoundaryQuestionUpdateRequest,
ObservabilityTimelineEventSummary,
AssetGenerateSpecsResponse,
AssetListResponse,
AssetResponse,
AssetRevisionSummary,
AssetSummary,
AssetUpdateRequest,
AssetUploadRequest,
AssetUploadResponse,
ClaimSummary,
CurrentUser,
CurrentUserResponse,
DraftAssembleResponse,
DraftFaqItem,
DraftListResponse,
DraftResponse,
DraftSummary,
DraftUpdateRequest,
ContentReviewIssueSummary,
ContentReviewReportSummary,
ContentReviewSuggestionSummary,
FinalApprovalChecklistItemSummary,
FinalApprovalChecklistResponse,
FinalApprovalPublishingSettings,
FinalApprovalRequest,
FinalApprovalResponse,
EvidenceSummary,
EvidenceCreateRequest,
EvidenceMatrixResponse,
EvidenceResponse,
EvidenceUpdateRequest,
PlanSectionSummary,
PlanSectionEditRequest,
PlanListResponse,
PlanResponse,
PlanRevisionRequest,
PlanSummary,
PlanUpdateRequest,
PublishCommitCreateResponse,
PublishCommitListResponse,
PublishCommitSummary,
PublishingDryRunResponse,
PublishingStatusResponse,
PublishingRules,
ResearchArtifactManifestSummary,
ResearchArtifactSummary,
ResearchListResponse,
ResearchStartResponse,
FinalReviewIssueSummary,
FinalReviewIssuesResponse,
LanguageReviewReportResponse,
LanguageReviewRunResponse,
ReviewSuggestionActionResponse,
ReviewSuggestionEditRequest,
ReviewActionResponse,
ReviewSummary,
FinalRevisionRequest,
FinalRevisionRequestResponse,
SeoReviewReportResponse,
SeoReviewRunResponse,
RunnerFileRef,
ScriptConfigVersionAuditEventListResponse,
ScriptConfigVersionAuditEventSummary,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse,
ScriptConfigVersionResponse,
ScriptConfigVersionSummary,
TargetSiteConfig,
TargetSiteConfigCreateRequest,
TargetSiteConfigResponse,
WorkflowEventSummary,
WorkflowStageCreateRequest,
WorkflowStagePart,
WorkflowStageReorderRequest,
WorkflowStageResponse,
WorkflowStageSummary,
WorkflowStageUpdateRequest,
WorkflowTemplateAuditEventListResponse,
WorkflowTemplateAuditEventSummary,
WorkflowTemplateCreateRequest,
WorkflowTemplateListResponse,
WorkflowTemplateResponse,
WorkflowTemplateSnapshot,
WorkflowTemplateStageSnapshot,
WorkflowTemplateSummary,
WorkflowTemplateUpdateRequest,
TargetSiteConfigUpdateRequest,
UserSummary,
)
CONTRACT_ENUMS: tuple[type[Enum], ...] = (
Role,
ArticleWorkflowStatus,
AgentJobStatus,
AgentJobType,
AgentJobErrorCategory,
ClaimSupportStatus,
ClaimRiskLevel,
PublishingStatus,
AssetStatus,
AssetType,
PlanReviewStatus,
ReviewStatus,
ReviewSuggestionStatus,
ReviewType,
ContentReviewKind,
ScriptConfigVersionStatus,
WorkflowTemplateStatus,
)
CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
UserSummary,
CurrentUser,
CurrentUserResponse,
PublishingRules,
TargetSiteConfig,
TargetSiteConfigCreateRequest,
TargetSiteConfigUpdateRequest,
TargetSiteConfigResponse,
ScriptConfigVersionSummary,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionResponse,
ScriptConfigVersionListResponse,
ScriptConfigVersionAuditEventSummary,
ScriptConfigVersionAuditEventListResponse,
WorkflowStagePart,
WorkflowStageSummary,
WorkflowTemplateSummary,
WorkflowTemplateCreateRequest,
WorkflowTemplateUpdateRequest,
WorkflowStageCreateRequest,
WorkflowStageUpdateRequest,
WorkflowStageReorderRequest,
WorkflowTemplateResponse,
WorkflowTemplateListResponse,
WorkflowStageResponse,
WorkflowTemplateAuditEventSummary,
WorkflowTemplateAuditEventListResponse,
WorkflowTemplateStageSnapshot,
WorkflowTemplateSnapshot,
ArticleCreateRequest,
ArticleSummary,
ArticleCreateResponse,
ArticleListResponse,
WorkflowEventSummary,
ObservabilityTimelineEventSummary,
BoundaryQuestionSummary,
BoundaryQuestionUpdateRequest,
BoundaryQuestionResponse,
BoundaryQuestionListResponse,
PlanSectionSummary,
PlanSectionEditRequest,
PlanSummary,
PlanUpdateRequest,
PlanRevisionRequest,
PlanResponse,
PlanListResponse,
ResearchArtifactSummary,
ResearchArtifactManifestSummary,
ResearchStartResponse,
ResearchListResponse,
EvidenceSummary,
EvidenceCreateRequest,
EvidenceUpdateRequest,
EvidenceResponse,
ClaimSummary,
EvidenceMatrixResponse,
DraftSummary,
DraftFaqItem,
DraftAssembleResponse,
DraftListResponse,
DraftResponse,
DraftUpdateRequest,
ContentReviewIssueSummary,
ContentReviewReportSummary,
ContentReviewSuggestionSummary,
FinalApprovalChecklistItemSummary,
FinalApprovalChecklistResponse,
FinalApprovalPublishingSettings,
FinalApprovalRequest,
FinalApprovalResponse,
FinalRevisionRequest,
FinalRevisionRequestResponse,
FinalReviewIssueSummary,
FinalReviewIssuesResponse,
LanguageReviewReportResponse,
LanguageReviewRunResponse,
AssetGenerateSpecsResponse,
AssetListResponse,
AssetResponse,
AssetRevisionSummary,
AssetSummary,
AssetUpdateRequest,
AssetUploadRequest,
AssetUploadResponse,
ReviewSummary,
ReviewActionResponse,
ReviewSuggestionActionResponse,
ReviewSuggestionEditRequest,
SeoReviewReportResponse,
SeoReviewRunResponse,
PublishCommitSummary,
PublishCommitCreateResponse,
PublishCommitListResponse,
PublishingDryRunResponse,
PublishingStatusResponse,
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]
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
USERS_TABLE = "users"
TARGET_SITES_TABLE = "target_sites"
SCRIPT_CONFIG_VERSIONS_TABLE = "script_config_versions"
WORKFLOW_TEMPLATES_TABLE = "workflow_templates"
WORKFLOW_TEMPLATE_STAGES_TABLE = "workflow_template_stages"
WORKFLOW_TEMPLATE_EVENTS_TABLE = "workflow_template_events"
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"
ARTICLE_REVIEW_REPORTS_TABLE = "article_review_reports"
ARTICLE_REVIEW_SUGGESTIONS_TABLE = "article_review_suggestions"
ASSETS_TABLE = "assets"
ASSET_REVISIONS_TABLE = "asset_revisions"
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,
WORKFLOW_TEMPLATES_TABLE,
WORKFLOW_TEMPLATE_STAGES_TABLE,
WORKFLOW_TEMPLATE_EVENTS_TABLE,
ARTICLES_TABLE,
BOUNDARY_QUESTIONS_TABLE,
ARTICLE_PLANS_TABLE,
PLAN_SECTIONS_TABLE,
EVIDENCE_ITEMS_TABLE,
CLAIMS_TABLE,
ARTICLE_DRAFTS_TABLE,
ARTICLE_REVIEW_REPORTS_TABLE,
ARTICLE_REVIEW_SUGGESTIONS_TABLE,
ASSETS_TABLE,
ASSET_REVISIONS_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,115 @@
from __future__ import annotations
import os
from pathlib import Path
class ObjectStorageClient:
def put_text(self, *, object_key: str, content: str) -> str:
raise NotImplementedError
def get_bytes(self, *, object_key: str) -> bytes:
raise NotImplementedError
def put_bytes(
self,
*,
object_key: str,
content: bytes,
content_type: str = "application/octet-stream",
) -> 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}"
def put_bytes(
self,
*,
object_key: str,
content: bytes,
content_type: str = "application/octet-stream",
) -> str:
del content_type
path = self.root / object_key
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
return f"file://{path}"
def get_bytes(self, *, object_key: str) -> bytes:
path = self.root / object_key
return path.read_bytes()
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:
return self.put_bytes(
object_key=object_key,
content=content.encode("utf-8"),
content_type="application/json; charset=utf-8",
)
def get_bytes(self, *, object_key: str) -> bytes:
response = self.client.get_object(
Bucket=self.bucket,
Key=object_key,
)
body = response.get("Body")
if body is None:
raise RuntimeError(f"Object storage response missing Body for key: {object_key}")
return body.read()
def put_bytes(
self,
*,
object_key: str,
content: bytes,
content_type: str = "application/octet-stream",
) -> str:
self.client.put_object(
Bucket=self.bucket,
Key=object_key,
Body=content,
ContentType=content_type,
)
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
+979
View File
@@ -0,0 +1,979 @@
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,
WorkflowTemplateStatus,
)
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
)
WORKFLOW_TEMPLATE_STATUS_VALUES = _quoted_values(
status.value for status in WorkflowTemplateStatus
)
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 workflow_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ({WORKFLOW_TEMPLATE_STATUS_VALUES})),
version INTEGER NOT NULL,
created_by UUID NOT NULL REFERENCES users(id),
updated_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
activated_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ
)
""",
"""
CREATE TABLE IF NOT EXISTS workflow_template_stages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_template_id UUID NOT NULL REFERENCES workflow_templates(id) ON DELETE CASCADE,
stable_key TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT NOT NULL,
position INTEGER NOT NULL,
owner_role TEXT NOT NULL CHECK (owner_role IN ('ADMIN', 'EDITOR')),
runner_profile_key TEXT NOT NULL,
required_inputs JSONB NOT NULL DEFAULT '[]'::jsonb,
expected_outputs JSONB NOT NULL DEFAULT '[]'::jsonb,
acceptance_criteria JSONB NOT NULL DEFAULT '[]'::jsonb,
requires_human_approval BOOLEAN NOT NULL DEFAULT false,
retry_policy JSONB NOT NULL DEFAULT '{}'::jsonb,
parts JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (workflow_template_id, stable_key)
)
""",
f"""
CREATE TABLE IF NOT EXISTS workflow_template_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_template_id UUID NOT NULL REFERENCES workflow_templates(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()
)
""",
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),
workflow_template_id UUID REFERENCES workflow_templates(id),
workflow_template_version INTEGER,
workflow_template_snapshot JSONB,
status TEXT NOT NULL CHECK (status IN ({ARTICLE_STATUS_VALUES})),
publishing_status TEXT NOT NULL CHECK (
publishing_status IN ({PUBLISHING_STATUS_VALUES})
),
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()
)
""",
"ALTER TABLE articles ADD COLUMN IF NOT EXISTS workflow_template_id UUID REFERENCES workflow_templates(id)",
"ALTER TABLE articles ADD COLUMN IF NOT EXISTS workflow_template_version INTEGER",
"ALTER TABLE articles ADD COLUMN IF NOT EXISTS workflow_template_snapshot JSONB",
"""
CREATE TABLE IF NOT EXISTS boundary_questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
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(),
review_status TEXT NOT NULL DEFAULT 'PENDING'
)
""",
"ALTER TABLE evidence_items ADD COLUMN IF NOT EXISTS review_status TEXT NOT NULL DEFAULT 'PENDING'",
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,
body_markdown TEXT NOT NULL DEFAULT '',
faq_items JSONB NOT NULL DEFAULT '[]'::jsonb,
visual_placeholders JSONB NOT NULL DEFAULT '[]'::jsonb,
evidence_references JSONB NOT NULL DEFAULT '[]'::jsonb,
unsupported_claim_warnings JSONB NOT NULL DEFAULT '[]'::jsonb,
based_on_draft_id UUID REFERENCES article_drafts(id) ON DELETE SET NULL,
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)
)
""",
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS body_markdown TEXT NOT NULL DEFAULT ''",
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS faq_items JSONB NOT NULL DEFAULT '[]'::jsonb",
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS visual_placeholders JSONB NOT NULL DEFAULT '[]'::jsonb",
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS evidence_references JSONB NOT NULL DEFAULT '[]'::jsonb",
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS unsupported_claim_warnings JSONB NOT NULL DEFAULT '[]'::jsonb",
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS based_on_draft_id UUID REFERENCES article_drafts(id) ON DELETE SET NULL",
"""
CREATE TABLE IF NOT EXISTS article_review_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
review_kind TEXT NOT NULL CHECK (review_kind IN ('SEO', 'LANGUAGE')),
draft_id UUID NOT NULL REFERENCES article_drafts(id) ON DELETE CASCADE,
score INTEGER NOT NULL,
issues JSONB NOT NULL DEFAULT '[]'::jsonb,
recommended_slug TEXT NOT NULL,
recommended_title TEXT NOT NULL,
schema_json JSONB NOT NULL DEFAULT '{}'::jsonb,
rules_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
"""
CREATE TABLE IF NOT EXISTS article_review_suggestions (
id UUID PRIMARY KEY,
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
review_kind TEXT NOT NULL CHECK (review_kind IN ('SEO', 'LANGUAGE')),
report_id UUID NOT NULL REFERENCES article_review_reports(id) ON DELETE CASCADE,
suggestion_key TEXT NOT NULL,
severity TEXT NOT NULL,
location TEXT NOT NULL,
message TEXT NOT NULL,
suggested_fix TEXT,
suggested_rewrite TEXT,
patch JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL CHECK (status IN ('PENDING', 'ACCEPTED', 'REJECTED', 'EDITED')),
applied_text TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (article_id, review_kind, suggestion_key)
)
""",
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,
section_id UUID REFERENCES plan_sections(id) ON DELETE SET NULL,
asset_type TEXT NOT NULL CHECK (asset_type IN ({ASSET_TYPE_VALUES})),
title TEXT NOT NULL,
prompt TEXT,
object_key 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()
)
""",
"ALTER TABLE assets ADD COLUMN IF NOT EXISTS section_id UUID REFERENCES plan_sections(id) ON DELETE SET NULL",
"ALTER TABLE assets ADD COLUMN IF NOT EXISTS object_key TEXT",
f"""
CREATE TABLE IF NOT EXISTS asset_revisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
revision_index INTEGER NOT NULL,
action TEXT NOT NULL,
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 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,
payload 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",
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS payload JSONB NOT NULL DEFAULT '{}'::jsonb",
"""
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_workflow_templates_slug ON workflow_templates (slug)",
"""
CREATE INDEX IF NOT EXISTS idx_workflow_templates_status_updated
ON workflow_templates (status, updated_at DESC)
""",
"""
CREATE INDEX IF NOT EXISTS idx_workflow_template_stages_order
ON workflow_template_stages (workflow_template_id, position)
""",
"""
CREATE INDEX IF NOT EXISTS idx_workflow_template_events_history
ON workflow_template_events (workflow_template_id, created_at)
""",
"""
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_articles_workflow_template
ON articles (workflow_template_id, workflow_template_version)
""",
"""
CREATE INDEX IF NOT EXISTS idx_agent_jobs_queue
ON agent_jobs (status, queued_at)
""",
"""
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_asset_revisions_asset
ON asset_revisions (asset_id, revision_index, 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)
""",
"""
CREATE INDEX IF NOT EXISTS idx_article_review_reports_article
ON article_review_reports (article_id, review_kind, created_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_article_review_suggestions_report
ON article_review_suggestions (report_id, status)
""",
)
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 workflow_templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL,
status TEXT NOT NULL,
version INTEGER NOT NULL,
created_by TEXT NOT NULL,
updated_by TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
activated_at TEXT,
archived_at TEXT
)
""",
"""
CREATE TABLE IF NOT EXISTS workflow_template_stages (
id TEXT PRIMARY KEY,
workflow_template_id TEXT NOT NULL,
stable_key TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT NOT NULL,
position INTEGER NOT NULL,
owner_role TEXT NOT NULL,
runner_profile_key TEXT NOT NULL,
required_inputs TEXT NOT NULL DEFAULT '[]',
expected_outputs TEXT NOT NULL DEFAULT '[]',
acceptance_criteria TEXT NOT NULL DEFAULT '[]',
requires_human_approval INTEGER NOT NULL DEFAULT 0,
retry_policy TEXT NOT NULL DEFAULT '{}',
parts TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (workflow_template_id, stable_key)
)
""",
"""
CREATE TABLE IF NOT EXISTS workflow_template_events (
id TEXT PRIMARY KEY,
workflow_template_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 TABLE IF NOT EXISTS articles (
id TEXT PRIMARY KEY,
target_site_id TEXT NOT NULL,
workflow_template_id TEXT,
workflow_template_version INTEGER,
workflow_template_snapshot TEXT,
status TEXT NOT NULL,
publishing_status TEXT NOT NULL,
brief_description TEXT NOT NULL,
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,
review_status TEXT NOT NULL DEFAULT 'PENDING'
)
""",
"""
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,
body_markdown TEXT NOT NULL DEFAULT '',
faq_items TEXT NOT NULL DEFAULT '[]',
visual_placeholders TEXT NOT NULL DEFAULT '[]',
evidence_references TEXT NOT NULL DEFAULT '[]',
unsupported_claim_warnings TEXT NOT NULL DEFAULT '[]',
based_on_draft_id 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 article_review_reports (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
review_kind TEXT NOT NULL,
draft_id TEXT NOT NULL,
score INTEGER NOT NULL,
issues TEXT NOT NULL DEFAULT '[]',
recommended_slug TEXT NOT NULL,
recommended_title TEXT NOT NULL,
schema_json TEXT NOT NULL DEFAULT '{}',
rules_snapshot TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS article_review_suggestions (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
review_kind TEXT NOT NULL,
report_id TEXT NOT NULL,
suggestion_key TEXT NOT NULL,
severity TEXT NOT NULL,
location TEXT NOT NULL,
message TEXT NOT NULL,
suggested_fix TEXT,
suggested_rewrite TEXT,
patch TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL,
applied_text TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (article_id, review_kind, suggestion_key)
)
""",
"""
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
section_id TEXT,
asset_type TEXT NOT NULL,
title TEXT NOT NULL,
prompt TEXT,
object_key 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 asset_revisions (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
asset_id TEXT NOT NULL,
revision_index INTEGER NOT NULL,
action TEXT NOT NULL,
actor_user_id TEXT,
payload TEXT NOT NULL DEFAULT '{}',
created_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 '[]',
payload 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_workflow_templates_slug ON workflow_templates (slug)",
"""
CREATE INDEX IF NOT EXISTS idx_workflow_templates_status_updated
ON workflow_templates (status, updated_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_workflow_template_stages_order
ON workflow_template_stages (workflow_template_id, position)
""",
"""
CREATE INDEX IF NOT EXISTS idx_workflow_template_events_history
ON workflow_template_events (workflow_template_id, created_at)
""",
"""
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_articles_workflow_template
ON articles (workflow_template_id, workflow_template_version)
""",
"""
CREATE INDEX IF NOT EXISTS idx_agent_jobs_queue
ON agent_jobs (status, queued_at)
""",
"""
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_asset_revisions_asset
ON asset_revisions (asset_id, revision_index, 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)
""",
"""
CREATE INDEX IF NOT EXISTS idx_article_review_reports_article
ON article_review_reports (article_id, review_kind, created_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_article_review_suggestions_report
ON article_review_suggestions (report_id, status)
""",
)
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
+72 -3
View File
@@ -1,10 +1,60 @@
from fastapi import FastAPI import os
from fastapi.responses import JSONResponse from typing import Any
from src.infrastructure.dependencies import check_dependencies from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
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.assets import router as assets_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.drafts import router as drafts_router
from src.presentation.routes.evidence import router as evidence_router
from src.presentation.routes.final_approval import router as final_approval_router
from src.presentation.routes.plans import router as plans_router
from src.presentation.routes.publishing import router as publishing_router
from src.presentation.routes.reviews import router as reviews_router
from src.presentation.routes.sites import router as sites_router
from src.presentation.routes.workflow_templates import router as workflow_templates_router
app = FastAPI(title="AI Content Pipeline Backend") app = FastAPI(title="AI Content Pipeline Backend")
_allowed_origins = [
origin.strip()
for origin in os.getenv(
"PIPELINE_CORS_ALLOW_ORIGINS",
"http://localhost:3000,http://localhost:13300",
).split(",")
if origin.strip()
]
app.add_middleware(
CORSMiddleware,
allow_origins=_allowed_origins,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router)
app.include_router(articles_router)
app.include_router(assets_router)
app.include_router(boundary_questions_router)
app.include_router(plans_router)
app.include_router(evidence_router)
app.include_router(drafts_router)
app.include_router(reviews_router)
app.include_router(final_approval_router)
app.include_router(publishing_router)
app.include_router(agent_jobs_router)
app.include_router(internal_agent_jobs_router)
app.include_router(sites_router)
app.include_router(workflow_templates_router)
@app.get("/health") @app.get("/health")
@@ -14,6 +64,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 +77,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,143 @@
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,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AgentJobResponse:
try:
return retry_agent_job(
repository,
job_id,
actor_user_id=current_user.id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except PermissionError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.post("/agent-jobs/{job_id}/cancel", response_model=AgentJobResponse)
def post_cancel_agent_job(
job_id: UUID,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AgentJobResponse:
try:
return cancel_agent_job(
repository,
job_id,
actor_user_id=current_user.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,82 @@
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
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(error),
) 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,
current_user: 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,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Not Found",
) from error
@@ -0,0 +1,149 @@
from __future__ import annotations
import base64
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from src.application.assets import (
approve_asset,
generate_asset_specs,
list_assets,
update_asset,
upload_asset_file,
)
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
AssetGenerateSpecsResponse,
AssetListResponse,
AssetResponse,
AssetUpdateRequest,
AssetUploadRequest,
AssetUploadResponse,
CurrentUser,
)
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=["assets"])
@router.post(
"/articles/{article_id}/assets/generate-specs",
response_model=AssetGenerateSpecsResponse,
status_code=status.HTTP_201_CREATED,
)
def post_generate_asset_specs(
article_id: UUID,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AssetGenerateSpecsResponse:
try:
return generate_asset_specs(
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}/assets",
response_model=AssetListResponse,
)
def get_article_assets(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AssetListResponse:
try:
return list_assets(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}/assets/{asset_id}",
response_model=AssetResponse,
)
def patch_article_asset(
article_id: UUID,
asset_id: UUID,
request: AssetUpdateRequest,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AssetResponse:
try:
return update_asset(
repository,
article_id=article_id,
asset_id=asset_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}/assets/{asset_id}/approve",
response_model=AssetResponse,
)
def post_approve_asset(
article_id: UUID,
asset_id: UUID,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AssetResponse:
try:
return approve_asset(
repository,
article_id=article_id,
asset_id=asset_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}/assets/{asset_id}/upload",
response_model=AssetUploadResponse,
)
def post_upload_asset_file(
article_id: UUID,
asset_id: UUID,
request: AssetUploadRequest,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AssetUploadResponse:
try:
payload = base64.b64decode(request.content_base64.encode("utf-8"), validate=True)
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Invalid base64 payload",
) from error
if not payload:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Uploaded file is empty",
)
try:
return upload_asset_file(
repository,
article_id=article_id,
asset_id=asset_id,
filename=request.filename,
content_type=request.content_type,
content=payload,
current_user=current_user,
object_storage=open_object_storage_client(),
)
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,93 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from src.application.drafts import assemble_draft, get_draft, list_drafts, update_draft
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
CurrentUser,
DraftAssembleResponse,
DraftListResponse,
DraftResponse,
DraftUpdateRequest,
)
from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles
router = APIRouter(prefix="/api", tags=["drafts"])
@router.post(
"/articles/{article_id}/draft/assemble",
response_model=DraftAssembleResponse,
status_code=status.HTTP_201_CREATED,
)
def post_assemble_draft(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> DraftAssembleResponse:
try:
return assemble_draft(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, ValueError) as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.get(
"/articles/{article_id}/drafts",
response_model=DraftListResponse,
)
def get_article_drafts(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> DraftListResponse:
try:
return list_drafts(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}/drafts/{draft_id}",
response_model=DraftResponse,
)
def get_article_draft(
article_id: UUID,
draft_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> DraftResponse:
try:
return get_draft(repository, article_id=article_id, draft_id=draft_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.patch(
"/articles/{article_id}/drafts/{draft_id}",
response_model=DraftResponse,
)
def patch_article_draft(
article_id: UUID,
draft_id: UUID,
request: DraftUpdateRequest,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> DraftResponse:
try:
return update_draft(
repository,
article_id=article_id,
draft_id=draft_id,
request=request,
)
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
@@ -0,0 +1,136 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from src.application.evidence import (
add_evidence,
approve_final,
get_evidence_matrix,
remove_evidence,
start_draft,
update_evidence,
)
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
AgentJobListResponse,
ArticleCreateResponse,
CurrentUser,
EvidenceMatrixResponse,
EvidenceResponse,
EvidenceCreateRequest,
EvidenceUpdateRequest,
)
from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles
router = APIRouter(prefix="/api", tags=["evidence"])
@router.get(
"/articles/{article_id}/evidence",
response_model=EvidenceMatrixResponse,
)
def get_article_evidence(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> EvidenceMatrixResponse:
try:
return get_evidence_matrix(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}/evidence/{evidence_id}",
response_model=EvidenceResponse,
)
def patch_evidence(
article_id: UUID,
evidence_id: UUID,
request: EvidenceUpdateRequest,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> EvidenceResponse:
try:
return update_evidence(
repository,
article_id=article_id,
evidence_id=evidence_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}/evidence",
response_model=EvidenceResponse,
status_code=status.HTTP_201_CREATED,
)
def post_evidence(
article_id: UUID,
request: EvidenceCreateRequest,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> EvidenceResponse:
try:
return add_evidence(repository, article_id=article_id, request=request)
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
@router.delete(
"/articles/{article_id}/evidence/{evidence_id}",
status_code=status.HTTP_200_OK,
)
def delete_evidence(
article_id: UUID,
evidence_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> None:
try:
remove_evidence(repository, article_id=article_id, evidence_id=evidence_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/draft/start",
response_model=AgentJobListResponse,
status_code=status.HTTP_202_ACCEPTED,
)
def post_start_draft(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AgentJobListResponse:
try:
return start_draft(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
@router.post(
"/articles/{article_id}/final/approve",
response_model=ArticleCreateResponse,
)
def post_final_approve(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ArticleCreateResponse:
try:
return approve_final(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,89 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from src.application.final_approval import (
approve_final_article,
get_final_approval_checklist,
request_final_revision,
)
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
CurrentUser,
FinalApprovalChecklistResponse,
FinalApprovalRequest,
FinalApprovalResponse,
FinalRevisionRequest,
FinalRevisionRequestResponse,
)
from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles
router = APIRouter(prefix="/api", tags=["final-approval"])
@router.get(
"/articles/{article_id}/final-approval/checklist",
response_model=FinalApprovalChecklistResponse,
)
def get_article_final_approval_checklist(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> FinalApprovalChecklistResponse:
try:
return get_final_approval_checklist(
repository,
article_id=article_id,
request=None,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/final-approval",
response_model=FinalApprovalResponse,
)
def post_article_final_approval(
article_id: UUID,
request: FinalApprovalRequest,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> FinalApprovalResponse:
try:
return approve_final_article(
repository,
article_id=article_id,
request=request,
actor_user_id=current_user.id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except PermissionError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.post(
"/articles/{article_id}/final-revision-request",
response_model=FinalRevisionRequestResponse,
)
def post_article_final_revision_request(
article_id: UUID,
request: FinalRevisionRequest,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> FinalRevisionRequestResponse:
try:
return request_final_revision(
repository,
article_id=article_id,
request=request,
actor_user_id=current_user.id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@@ -0,0 +1,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,99 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from src.application.publishing import (
create_publish_commit,
get_publishing_status,
list_publish_commits,
run_publishing_dry_run,
)
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
CurrentUser,
PublishCommitCreateResponse,
PublishCommitListResponse,
PublishingDryRunResponse,
PublishingStatusResponse,
)
from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles
router = APIRouter(prefix="/api", tags=["publishing"])
@router.post(
"/articles/{article_id}/publishing/dry-run",
response_model=PublishingDryRunResponse,
status_code=status.HTTP_201_CREATED,
)
def post_publishing_dry_run(
article_id: UUID,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> PublishingDryRunResponse:
try:
return run_publishing_dry_run(
repository,
article_id=article_id,
actor_user_id=current_user.id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except (PermissionError, ValueError, RuntimeError) as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.post(
"/articles/{article_id}/publishing/create-commit",
response_model=PublishCommitCreateResponse,
status_code=status.HTTP_201_CREATED,
)
def post_publishing_create_commit(
article_id: UUID,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> PublishCommitCreateResponse:
try:
return create_publish_commit(
repository,
article_id=article_id,
actor_user_id=current_user.id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except (PermissionError, ValueError, RuntimeError) as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.get(
"/articles/{article_id}/publishing/status",
response_model=PublishingStatusResponse,
)
def get_article_publishing_status(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> PublishingStatusResponse:
try:
return get_publishing_status(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}/publishing/commits",
response_model=PublishCommitListResponse,
)
def get_article_publishing_commits(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> PublishCommitListResponse:
try:
return list_publish_commits(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,244 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from src.application.reviews import (
accept_suggestion,
edit_suggestion,
get_final_review_issues,
get_language_report,
get_seo_report,
reject_suggestion,
run_language_review,
run_seo_review,
)
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
ContentReviewKind,
CurrentUser,
FinalReviewIssuesResponse,
LanguageReviewReportResponse,
LanguageReviewRunResponse,
ReviewSuggestionActionResponse,
ReviewSuggestionEditRequest,
SeoReviewReportResponse,
SeoReviewRunResponse,
)
from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles
router = APIRouter(prefix="/api", tags=["reviews"])
@router.post(
"/articles/{article_id}/seo/review",
response_model=SeoReviewRunResponse,
status_code=status.HTTP_201_CREATED,
)
def post_run_seo_review(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> SeoReviewRunResponse:
try:
return run_seo_review(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
@router.get(
"/articles/{article_id}/seo/report",
response_model=SeoReviewReportResponse,
)
def get_article_seo_report(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> SeoReviewReportResponse:
try:
return get_seo_report(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/language/review",
response_model=LanguageReviewRunResponse,
status_code=status.HTTP_201_CREATED,
)
def post_run_language_review(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> LanguageReviewRunResponse:
try:
return run_language_review(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
@router.get(
"/articles/{article_id}/language/report",
response_model=LanguageReviewReportResponse,
)
def get_article_language_report(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> LanguageReviewReportResponse:
try:
return get_language_report(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/seo/suggestions/{suggestion_id}/accept",
response_model=ReviewSuggestionActionResponse,
)
def post_accept_seo_suggestion(
article_id: UUID,
suggestion_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ReviewSuggestionActionResponse:
try:
return accept_suggestion(
repository,
article_id=article_id,
review_kind=ContentReviewKind.SEO,
suggestion_id=suggestion_id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/seo/suggestions/{suggestion_id}/reject",
response_model=ReviewSuggestionActionResponse,
)
def post_reject_seo_suggestion(
article_id: UUID,
suggestion_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ReviewSuggestionActionResponse:
try:
return reject_suggestion(
repository,
article_id=article_id,
review_kind=ContentReviewKind.SEO,
suggestion_id=suggestion_id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/seo/suggestions/{suggestion_id}/edit",
response_model=ReviewSuggestionActionResponse,
)
def post_edit_seo_suggestion(
article_id: UUID,
suggestion_id: UUID,
request: ReviewSuggestionEditRequest,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ReviewSuggestionActionResponse:
try:
return edit_suggestion(
repository,
article_id=article_id,
review_kind=ContentReviewKind.SEO,
suggestion_id=suggestion_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}/language/suggestions/{suggestion_id}/accept",
response_model=ReviewSuggestionActionResponse,
)
def post_accept_language_suggestion(
article_id: UUID,
suggestion_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ReviewSuggestionActionResponse:
try:
return accept_suggestion(
repository,
article_id=article_id,
review_kind=ContentReviewKind.LANGUAGE,
suggestion_id=suggestion_id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/language/suggestions/{suggestion_id}/reject",
response_model=ReviewSuggestionActionResponse,
)
def post_reject_language_suggestion(
article_id: UUID,
suggestion_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ReviewSuggestionActionResponse:
try:
return reject_suggestion(
repository,
article_id=article_id,
review_kind=ContentReviewKind.LANGUAGE,
suggestion_id=suggestion_id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/articles/{article_id}/language/suggestions/{suggestion_id}/edit",
response_model=ReviewSuggestionActionResponse,
)
def post_edit_language_suggestion(
article_id: UUID,
suggestion_id: UUID,
request: ReviewSuggestionEditRequest,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ReviewSuggestionActionResponse:
try:
return edit_suggestion(
repository,
article_id=article_id,
review_kind=ContentReviewKind.LANGUAGE,
suggestion_id=suggestion_id,
request=request,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.get(
"/articles/{article_id}/final-review/issues",
response_model=FinalReviewIssuesResponse,
)
def get_article_final_review_issues(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> FinalReviewIssuesResponse:
try:
return get_final_review_issues(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,173 @@
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_audit_events,
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,
ScriptConfigVersionAuditEventListResponse,
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.get(
"/sites/{site_id}/publishing-config/audit",
response_model=ScriptConfigVersionAuditEventListResponse,
)
def get_script_config_audit_events(
site_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ScriptConfigVersionAuditEventListResponse:
try:
return list_script_config_audit_events(repository, site_id)
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",
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
@@ -0,0 +1,257 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from src.application.workflow_templates import (
activate_workflow_template,
add_workflow_stage,
archive_workflow_template,
create_workflow_template,
delete_workflow_stage,
get_workflow_template,
list_workflow_template_audit_events,
list_workflow_templates,
reorder_workflow_stages,
update_workflow_stage,
update_workflow_template,
)
from src.domain.auth import ADMIN_ROLES, EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
CurrentUser,
WorkflowStageCreateRequest,
WorkflowStageReorderRequest,
WorkflowStageResponse,
WorkflowStageUpdateRequest,
WorkflowTemplateAuditEventListResponse,
WorkflowTemplateCreateRequest,
WorkflowTemplateListResponse,
WorkflowTemplateResponse,
WorkflowTemplateUpdateRequest,
)
from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles
router = APIRouter(prefix="/api", tags=["workflow_templates"])
@router.post(
"/admin/workflows",
response_model=WorkflowTemplateResponse,
status_code=status.HTTP_201_CREATED,
)
def post_workflow_template(
request: WorkflowTemplateCreateRequest,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
return create_workflow_template(
repository,
request=request,
current_user=current_user,
)
@router.get("/admin/workflows", response_model=WorkflowTemplateListResponse)
def get_workflow_templates(
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateListResponse:
return list_workflow_templates(repository, current_user=current_user)
@router.get(
"/admin/workflows/{workflow_id}",
response_model=WorkflowTemplateResponse,
)
def get_workflow_template_detail(
workflow_id: UUID,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
try:
return get_workflow_template(
repository,
workflow_id=workflow_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.patch(
"/admin/workflows/{workflow_id}",
response_model=WorkflowTemplateResponse,
)
def patch_workflow_template(
workflow_id: UUID,
request: WorkflowTemplateUpdateRequest,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
try:
return update_workflow_template(
repository,
workflow_id=workflow_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
except ValueError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.post(
"/admin/workflows/{workflow_id}/stages",
response_model=WorkflowStageResponse,
status_code=status.HTTP_201_CREATED,
)
def post_workflow_stage(
workflow_id: UUID,
request: WorkflowStageCreateRequest,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowStageResponse:
try:
return add_workflow_stage(
repository,
workflow_id=workflow_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
except ValueError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.patch(
"/admin/workflows/{workflow_id}/stages/{stage_id}",
response_model=WorkflowStageResponse,
)
def patch_workflow_stage(
workflow_id: UUID,
stage_id: UUID,
request: WorkflowStageUpdateRequest,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowStageResponse:
try:
return update_workflow_stage(
repository,
workflow_id=workflow_id,
stage_id=stage_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
except ValueError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.delete(
"/admin/workflows/{workflow_id}/stages/{stage_id}",
response_model=WorkflowTemplateResponse,
)
def delete_workflow_template_stage(
workflow_id: UUID,
stage_id: UUID,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
try:
return delete_workflow_stage(
repository,
workflow_id=workflow_id,
stage_id=stage_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
@router.post(
"/admin/workflows/{workflow_id}/stages/reorder",
response_model=WorkflowTemplateResponse,
)
def post_reorder_workflow_stages(
workflow_id: UUID,
request: WorkflowStageReorderRequest,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
try:
return reorder_workflow_stages(
repository,
workflow_id=workflow_id,
stage_ids=request.stage_ids,
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
@router.post(
"/admin/workflows/{workflow_id}/activate",
response_model=WorkflowTemplateResponse,
)
def post_activate_workflow_template(
workflow_id: UUID,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
try:
return activate_workflow_template(
repository,
workflow_id=workflow_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
@router.post(
"/admin/workflows/{workflow_id}/archive",
response_model=WorkflowTemplateResponse,
)
def post_archive_workflow_template(
workflow_id: UUID,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
try:
return archive_workflow_template(
repository,
workflow_id=workflow_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(
"/admin/workflows/{workflow_id}/audit",
response_model=WorkflowTemplateAuditEventListResponse,
)
def get_workflow_template_audit_events(
workflow_id: UUID,
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateAuditEventListResponse:
try:
return list_workflow_template_audit_events(
repository,
workflow_id=workflow_id,
)
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,391 @@
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_ADMIN_EMAIL = "admin@example.com"
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class AdminWorkflowTemplatesPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'workflow-templates.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_creates_workflow_template_with_two_editable_ordered_stages_and_structured_parts(
self,
) -> None:
admin_headers = {DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}
create_response = self.client.post(
"/api/admin/workflows",
headers=admin_headers,
json=self._workflow_payload("article-production-v1"),
)
self.assertEqual(201, create_response.status_code, create_response.text)
workflow = create_response.json()["workflow"]
self.assertEqual("article-production-v1", workflow["slug"])
self.assertEqual("DRAFT", workflow["status"])
self.assertEqual(1, workflow["version"])
self.assertEqual([], workflow["stages"])
self.assertTrue(workflow["created_at"])
self.assertTrue(workflow["updated_at"])
intake_response = self.client.post(
f"/api/admin/workflows/{workflow['id']}/stages",
headers=admin_headers,
json=self._intake_stage_payload(),
)
self.assertEqual(201, intake_response.status_code, intake_response.text)
intake_stage = intake_response.json()["stage"]
draft_response = self.client.post(
f"/api/admin/workflows/{workflow['id']}/stages",
headers=admin_headers,
json=self._draft_stage_payload(),
)
self.assertEqual(201, draft_response.status_code, draft_response.text)
draft_stage = draft_response.json()["stage"]
edited_draft_parts = [
{
"key": "draft-outline",
"type": "outline",
"title": "Edited draft outline",
"payload": {
"prompt": "Build a sourced outline before drafting.",
"config": {
"minimum_sections": 5,
"require_source_placeholders": True,
},
},
"acceptance_criteria": [
"Every section has a purpose.",
"Claims that need evidence are marked.",
],
},
{
"key": "draft-body",
"type": "generation",
"title": "Draft body",
"payload": {
"prompt": "Generate a complete longform draft.",
"config": {
"tone": "practical",
"include_evidence_markers": True,
},
},
"acceptance_criteria": [
"Draft includes all approved outline sections.",
"Unsupported claims remain marked for review.",
],
},
]
patch_response = self.client.patch(
f"/api/admin/workflows/{workflow['id']}/stages/{draft_stage['id']}",
headers=admin_headers,
json={
"display_name": "Draft and evidence assembly",
"description": "Create an evidence-aware article draft.",
"owner_role": "EDITOR",
"runner_profile_key": "draft-writer-v2",
"required_inputs": [
"approved_plan",
"evidence_matrix",
],
"expected_outputs": [
"article_draft",
"claim_evidence_map",
],
"acceptance_criteria": [
"Draft follows the approved plan.",
"Evidence markers are preserved.",
],
"requires_human_approval": True,
"retry_policy": {
"max_attempts": 2,
"backoff_seconds": 120,
},
"parts": edited_draft_parts,
},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
detail_response = self.client.get(
f"/api/admin/workflows/{workflow['id']}",
headers=admin_headers,
)
self.assertEqual(200, detail_response.status_code, detail_response.text)
detail = detail_response.json()["workflow"]
stages = detail["stages"]
self.assertEqual([1, 2], [stage["position"] for stage in stages])
self.assertEqual(
["intake-boundary-questions", "draft-assembly"],
[stage["stable_key"] for stage in stages],
)
self.assertEqual(intake_stage["id"], stages[0]["id"])
self.assertEqual(draft_stage["id"], stages[1]["id"])
self.assertEqual(self._intake_stage_payload()["parts"], stages[0]["parts"])
self.assertEqual("Draft and evidence assembly", stages[1]["display_name"])
self.assertEqual("draft-writer-v2", stages[1]["runner_profile_key"])
self.assertTrue(stages[1]["requires_human_approval"])
self.assertEqual(
{
"max_attempts": 2,
"backoff_seconds": 120,
},
stages[1]["retry_policy"],
)
self.assertEqual(edited_draft_parts, stages[1]["parts"])
def test_editor_mutation_is_denied_with_existing_authorization_error_shape(
self,
) -> None:
response = self.client.post(
"/api/admin/workflows",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json=self._workflow_payload("editor-denied-workflow"),
)
self.assertEqual(403, response.status_code, response.text)
self.assertEqual({"detail": "Forbidden"}, response.json())
def test_admin_reorders_activates_archives_and_audits_editor_visible_workflow(
self,
) -> None:
admin_headers = {DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}
editor_headers = {DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}
workflow = self.client.post(
"/api/admin/workflows",
headers=admin_headers,
json=self._workflow_payload("article-production-ops"),
).json()["workflow"]
workflow_id = workflow["id"]
intake_stage = self.client.post(
f"/api/admin/workflows/{workflow_id}/stages",
headers=admin_headers,
json=self._intake_stage_payload(),
).json()["stage"]
draft_stage = self.client.post(
f"/api/admin/workflows/{workflow_id}/stages",
headers=admin_headers,
json=self._draft_stage_payload(),
).json()["stage"]
reorder_response = self.client.post(
f"/api/admin/workflows/{workflow_id}/stages/reorder",
headers=admin_headers,
json={"stage_ids": [draft_stage["id"], intake_stage["id"]]},
)
self.assertEqual(200, reorder_response.status_code, reorder_response.text)
self.assertEqual(
["draft-assembly", "intake-boundary-questions"],
[
stage["stable_key"]
for stage in reorder_response.json()["workflow"]["stages"]
],
)
activate_response = self.client.post(
f"/api/admin/workflows/{workflow_id}/activate",
headers=admin_headers,
)
self.assertEqual(200, activate_response.status_code, activate_response.text)
activated = activate_response.json()["workflow"]
self.assertEqual("ACTIVE", activated["status"])
self.assertEqual(2, activated["version"])
editor_list_response = self.client.get(
"/api/admin/workflows",
headers=editor_headers,
)
self.assertEqual(200, editor_list_response.status_code, editor_list_response.text)
self.assertIn(
workflow_id,
[item["id"] for item in editor_list_response.json()["workflows"]],
)
editor_detail_response = self.client.get(
f"/api/admin/workflows/{workflow_id}",
headers=editor_headers,
)
self.assertEqual(200, editor_detail_response.status_code, editor_detail_response.text)
immutable_patch_response = self.client.patch(
f"/api/admin/workflows/{workflow_id}/stages/{draft_stage['id']}",
headers=admin_headers,
json={"display_name": "Should not mutate active template"},
)
self.assertEqual(409, immutable_patch_response.status_code, immutable_patch_response.text)
archive_response = self.client.post(
f"/api/admin/workflows/{workflow_id}/archive",
headers=admin_headers,
)
self.assertEqual(200, archive_response.status_code, archive_response.text)
self.assertEqual("ARCHIVED", archive_response.json()["workflow"]["status"])
archived_editor_list_response = self.client.get(
"/api/admin/workflows",
headers=editor_headers,
)
self.assertEqual(
200,
archived_editor_list_response.status_code,
archived_editor_list_response.text,
)
self.assertNotIn(
workflow_id,
[item["id"] for item in archived_editor_list_response.json()["workflows"]],
)
archived_editor_detail_response = self.client.get(
f"/api/admin/workflows/{workflow_id}",
headers=editor_headers,
)
self.assertEqual(
404,
archived_editor_detail_response.status_code,
archived_editor_detail_response.text,
)
audit_response = self.client.get(
f"/api/admin/workflows/{workflow_id}/audit",
headers=admin_headers,
)
self.assertEqual(200, audit_response.status_code, audit_response.text)
event_types = {
event["event_type"] for event in audit_response.json()["events"]
}
self.assertTrue(
{
"WORKFLOW_TEMPLATE_CREATED",
"WORKFLOW_STAGE_CREATED",
"WORKFLOW_STAGES_REORDERED",
"WORKFLOW_TEMPLATE_ACTIVATED",
"WORKFLOW_TEMPLATE_ARCHIVED",
}.issubset(event_types)
)
def _workflow_payload(self, slug: str) -> dict[str, str]:
return {
"name": "Article production workflow",
"slug": slug,
"description": "Reusable editorial workflow for longform article production.",
}
def _intake_stage_payload(self) -> dict[str, object]:
return {
"stable_key": "intake-boundary-questions",
"display_name": "Boundary question intake",
"description": "Collect required editorial context before planning.",
"position": 1,
"owner_role": "EDITOR",
"runner_profile_key": "boundary-question-agent-v1",
"required_inputs": [
"article_brief",
"target_site",
],
"expected_outputs": [
"answered_boundary_questions",
],
"acceptance_criteria": [
"All required boundary questions are answered.",
"Answers are tied to the source brief.",
],
"requires_human_approval": True,
"retry_policy": {
"max_attempts": 1,
"backoff_seconds": 0,
},
"parts": [
{
"key": "required-context-checklist",
"type": "checklist",
"title": "Required context checklist",
"payload": {
"prompt": "Identify missing audience, keyword, and source constraints.",
"config": {
"required_fields": [
"audience",
"primary_keyword",
"source_rules",
],
"block_on_missing": True,
},
},
"acceptance_criteria": [
"Missing required context is listed explicitly.",
"No free-text parsing is needed to read the checklist.",
],
}
],
}
def _draft_stage_payload(self) -> dict[str, object]:
return {
"stable_key": "draft-assembly",
"display_name": "Draft assembly",
"description": "Generate a first draft from the approved plan.",
"position": 2,
"owner_role": "EDITOR",
"runner_profile_key": "draft-writer-v1",
"required_inputs": [
"approved_plan",
],
"expected_outputs": [
"article_draft",
],
"acceptance_criteria": [
"Draft follows the approved plan.",
],
"requires_human_approval": False,
"retry_policy": {
"max_attempts": 2,
"backoff_seconds": 60,
},
"parts": [
{
"key": "draft-outline",
"type": "outline",
"title": "Draft outline",
"payload": {
"prompt": "Build a concise outline before drafting.",
"config": {
"minimum_sections": 4,
"require_source_placeholders": True,
},
},
"acceptance_criteria": [
"Every section has a purpose.",
],
}
],
}
if __name__ == "__main__":
unittest.main()
@@ -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,366 @@
from __future__ import annotations
import base64
import os
import sys
import tempfile
import unittest
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi.testclient import TestClient
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
from src.application.seed_data import seed_reference_data # noqa: E402
from src.infrastructure.repositories import open_backend_repository # noqa: E402
from src.presentation.dependencies import get_repository # noqa: E402
from src.presentation.main import app # noqa: E402
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class AssetsMediaLibraryPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
self.objects_root = Path(self.tmp_dir.name) / "objects"
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(self.objects_root)
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'assets-media-library.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_generate_specs_links_assets_to_approved_plan_sections(self) -> None:
article_id, plan = self._prepare_article_with_approved_plan()
response = self.client.post(
f"/api/articles/{article_id}/assets/generate-specs",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, response.status_code, response.text)
assets = response.json()["assets"]
self.assertEqual(len(plan["sections"]), len(assets))
section_ids = {section["id"] for section in plan["sections"]}
for asset in assets:
self.assertIn(asset["section_id"], section_ids)
self.assertEqual("GENERATED", asset["status"])
self.assertTrue(asset["title"])
def test_upload_stores_object_and_persists_object_key(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
asset_id = self._generate_specs(article_id)[0]["id"]
upload_response = self.client.post(
f"/api/articles/{article_id}/assets/{asset_id}/upload",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"filename": "hero.png",
"content_base64": base64.b64encode(b"fake-binary-image-content").decode("utf-8"),
"content_type": "image/png",
},
)
self.assertEqual(200, upload_response.status_code, upload_response.text)
asset = upload_response.json()["asset"]
self.assertEqual("PENDING", asset["status"])
self.assertTrue(asset["object_key"])
self.assertTrue(asset["file_url"].startswith("file://"))
self.assertTrue(
(self.objects_root / asset["object_key"]).exists(),
"Uploaded object must exist in local object storage",
)
persisted = (self.objects_root / asset["object_key"]).read_bytes()
self.assertEqual(b"fake-binary-image-content", persisted)
history = asset["history"]
self.assertGreaterEqual(len(history), 1)
self.assertEqual("ASSET_FILE_UPLOADED", history[-1]["action"])
def test_approve_asset_writes_workflow_event(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
asset_id = self._generate_specs(article_id)[0]["id"]
self._upload_asset(article_id, asset_id, filename="approve.png", payload=b"approve")
approve_response = self.client.post(
f"/api/articles/{article_id}/assets/{asset_id}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, approve_response.status_code, approve_response.text)
self.assertEqual("APPROVED", approve_response.json()["asset"]["status"])
article_detail = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, article_detail.status_code, article_detail.text)
events = article_detail.json()["workflow_events"]
approval_events = [event for event in events if event["event_type"] == "ASSET_APPROVED"]
self.assertTrue(approval_events, "Approval must write workflow event")
self.assertEqual(asset_id, approval_events[-1]["payload"]["asset_id"])
def test_rejected_assets_are_excluded_from_draft_publish_bundle(self) -> None:
article_id = self._prepare_article_with_completed_scaffolds()
generated_assets = self._generate_specs(article_id)
self.assertGreaterEqual(len(generated_assets), 2)
approved_asset = generated_assets[0]
rejected_asset = generated_assets[1]
approved_upload = self._upload_asset(
article_id,
approved_asset["id"],
filename="approved.png",
payload=b"approved-image",
)
self._approve_asset(article_id, approved_asset["id"])
rejected_upload = self._upload_asset(
article_id,
rejected_asset["id"],
filename="rejected.png",
payload=b"rejected-image",
)
self._patch_asset(
article_id,
rejected_asset["id"],
request={"status": "REJECTED"},
)
draft = self._assemble_draft(article_id)
self.assertIn(approved_upload["file_url"], draft["body_markdown"])
self.assertNotIn(rejected_upload["file_url"], draft["body_markdown"])
self.assertIn(approved_upload["file_url"], "\n".join(draft["visual_placeholders"]))
self.assertNotIn(rejected_upload["file_url"], "\n".join(draft["visual_placeholders"]))
def test_replacement_preserves_audit_history(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
asset_id = self._generate_specs(article_id)[0]["id"]
first = self._upload_asset(article_id, asset_id, filename="first.png", payload=b"first")
second = self._upload_asset(article_id, asset_id, filename="second.png", payload=b"second")
self.assertNotEqual(first["object_key"], second["object_key"])
list_response = self.client.get(
f"/api/articles/{article_id}/assets",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, list_response.status_code, list_response.text)
asset = next(item for item in list_response.json()["assets"] if item["id"] == asset_id)
history = asset["history"]
actions = [item["action"] for item in history]
self.assertIn("ASSET_FILE_UPLOADED", actions)
self.assertIn("ASSET_FILE_REPLACED", actions)
replaced_event = next(item for item in history if item["action"] == "ASSET_FILE_REPLACED")
self.assertEqual(first["object_key"], replaced_event["payload"]["previous_object_key"])
self.assertEqual(second["object_key"], replaced_event["payload"]["next_object_key"])
def test_media_library_lists_assets_with_required_fields(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
asset_id = self._generate_specs(article_id)[0]["id"]
self._upload_asset(article_id, asset_id, filename="library.png", payload=b"library")
self._patch_asset(
article_id,
asset_id,
request={
"title": "Library Hero",
"alt_text": "Dashboard on laptop screen",
"caption": "Screenshot used in introduction section.",
},
)
list_response = self.client.get(
f"/api/articles/{article_id}/assets",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, list_response.status_code, list_response.text)
asset = next(item for item in list_response.json()["assets"] if item["id"] == asset_id)
self.assertEqual("Library Hero", asset["title"])
self.assertTrue(asset["asset_type"])
self.assertTrue(asset["status"])
self.assertTrue(asset["file_url"])
self.assertEqual("Dashboard on laptop screen", asset["alt_text"])
self.assertEqual("Screenshot used in introduction section.", asset["caption"])
def test_draft_preview_uses_approved_asset_references(self) -> None:
article_id = self._prepare_article_with_completed_scaffolds()
asset = self._generate_specs(article_id)[0]
uploaded = self._upload_asset(
article_id,
asset["id"],
filename="preview.png",
payload=b"preview",
)
self._approve_asset(article_id, asset["id"])
draft = self._assemble_draft(article_id)
self.assertIn(uploaded["file_url"], draft["body_markdown"])
self.assertIn(uploaded["file_url"], "\n".join(draft["visual_placeholders"]))
def _assemble_draft(self, article_id: UUID) -> dict[str, Any]:
response = self.client.post(
f"/api/articles/{article_id}/draft/assemble",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, response.status_code, response.text)
return response.json()["draft"]
def _upload_asset(self, article_id: UUID, asset_id: str, *, filename: str, payload: bytes) -> dict[str, Any]:
response = self.client.post(
f"/api/articles/{article_id}/assets/{asset_id}/upload",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"filename": filename,
"content_base64": base64.b64encode(payload).decode("utf-8"),
"content_type": "image/png",
},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["asset"]
def _approve_asset(self, article_id: UUID, asset_id: str) -> None:
response = self.client.post(
f"/api/articles/{article_id}/assets/{asset_id}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, response.status_code, response.text)
def _patch_asset(self, article_id: UUID, asset_id: str, *, request: dict[str, Any]) -> dict[str, Any]:
response = self.client.patch(
f"/api/articles/{article_id}/assets/{asset_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json=request,
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["asset"]
def _generate_specs(self, article_id: UUID) -> list[dict[str, Any]]:
response = self.client.post(
f"/api/articles/{article_id}/assets/generate-specs",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, response.status_code, response.text)
return response.json()["assets"]
def _prepare_article_with_assembled_draft(self) -> UUID:
article_id = self._prepare_article_with_completed_scaffolds()
self._assemble_draft(article_id)
return article_id
def _prepare_article_with_completed_scaffolds(self) -> UUID:
article_id, _ = self._prepare_article_with_approved_plan()
self._ensure_evidence_ready(article_id)
section_jobs = self._start_parallel_production(article_id)
self._complete_all_section_jobs_success(section_jobs)
return article_id
def _prepare_article_with_approved_plan(self) -> tuple[UUID, dict[str, Any]]:
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": "Assets library integration coverage",
"working_title": "Assets and Media Library",
"content_type": "longform_guide",
"primary_keyword": "assets media library",
},
).json()["article"]
article_id = UUID(article["id"])
questions = self.client.post(
f"/api/articles/{article_id}/boundary-questions/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["questions"]
for question in questions:
if question["is_required"]:
patch_response = self.client.patch(
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"answer": f"Answer for {question['category']}"},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
submit_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/submit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, submit_response.status_code, submit_response.text)
plan_response = self.client.post(
f"/api/articles/{article_id}/plan/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, plan_response.status_code, plan_response.text)
plan = plan_response.json()["plan"]
self.assertGreaterEqual(len(plan["sections"]), 1)
approve_plan_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_plan_response.status_code, approve_plan_response.text)
return article_id, plan
def _complete_all_section_jobs_success(self, section_jobs: list[dict[str, Any]]) -> None:
for index, job in enumerate(section_jobs, start=1):
complete_response = self.client.post(
f"/internal/agent-jobs/{job['id']}/complete",
json={
"workspace_path": f"/tmp/{job['id']}",
"stdout": "fake section scaffolding runner\n",
"stderr": "",
"exit_code": 0,
"duration_ms": 1,
"output": {
"status": "SUCCEEDED",
"output_files": [{"path": f"outputs/section-{index}.md"}],
"payload": {
"used_evidence_ids": job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": f"## {job['payload']['heading']}\n\nSection {index}.",
},
},
},
)
self.assertEqual(200, complete_response.status_code, complete_response.text)
self.assertEqual("SUCCEEDED", complete_response.json()["job"]["status"])
def _start_parallel_production(self, article_id: UUID) -> list[dict[str, Any]]:
response = self.client.post(
f"/api/articles/{article_id}/draft/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(202, response.status_code, response.text)
jobs = response.json()["jobs"]
section_jobs = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"]
self.assertTrue(section_jobs)
return section_jobs
def _ensure_evidence_ready(self, article_id: UUID) -> None:
research_response = self.client.post(
f"/api/articles/{article_id}/research/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, research_response.status_code, research_response.text)
evidence_response = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,408 @@
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"]
questions_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, questions_response.status_code, questions_response.text)
for question in questions_response.json()["questions"]:
if question["is_required"]:
patch_response = self.client.patch(
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"answer": f"Answer for {question['category']}"},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
submit_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/submit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, submit_response.status_code, submit_response.text)
plan_response = self.client.post(
f"/api/articles/{article_id}/plan/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, plan_response.status_code, plan_response.text)
plan_id = plan_response.json()["plan"]["id"]
review_response = self.client.post(
f"/api/articles/{article_id}/plans/{plan_id}/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,56 @@
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
class DemoBrowserCorsPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'demo-browser-cors.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_demo_frontend_origin_can_call_backend_with_demo_user_header(self) -> None:
response = self.client.options(
"/api/admin/workflows",
headers={
"Origin": "http://localhost:13300",
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "X-Demo-User-Email",
},
)
self.assertEqual(200, response.status_code, response.text)
self.assertEqual(
"http://localhost:13300",
response.headers.get("access-control-allow-origin"),
)
self.assertIn(
"X-Demo-User-Email",
response.headers.get("access-control-allow-headers", ""),
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,395 @@
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from pathlib import Path
from typing import Any
from uuid import uuid4
from fastapi.testclient import TestClient
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
from src.application.seed_data import seed_reference_data # noqa: E402
from src.infrastructure.repositories import open_backend_repository # noqa: E402
from src.presentation.dependencies import get_repository # noqa: E402
from src.presentation.main import app # noqa: E402
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_ADMIN_EMAIL = "admin@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class DraftAssemblyPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects")
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'draft-assembly.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_assemble_draft_includes_all_sections_in_order_and_full_payload(self) -> None:
article_id, section_headings = self._prepare_article_for_draft_assembly()
section_jobs = self._start_parallel_production(article_id)
for index, job in enumerate(section_jobs, start=1):
if index == 1:
draft_markdown = (
f"## {job['payload']['heading']}\n\n"
"Use [official documentation](https://example.com/docs) for setup.\n\n"
"| Step | Result |\n"
"| --- | --- |\n"
"| 1 | Ready |\n\n"
"![Existing mock](placeholder://existing/mock)\n"
)
else:
draft_markdown = (
f"## {job['payload']['heading']}\n\n"
f"Body for section {index}."
)
completed = self._complete_job(
job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": f"outputs/section-{index}.md"}],
"payload": {
"used_evidence_ids": job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"suggested_visuals": [{"title": f"Visual {index}"}],
"draft_markdown": draft_markdown,
},
},
)
self.assertEqual("SUCCEEDED", completed["status"])
assemble_response = self.client.post(
f"/api/articles/{article_id}/draft/assemble",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, assemble_response.status_code, assemble_response.text)
draft = assemble_response.json()["draft"]
self.assertEqual(1, draft["version"])
self.assertTrue(draft["meta_title"])
self.assertTrue(draft["meta_description"])
self.assertGreaterEqual(len(draft["faq_items"]), 1)
self.assertGreaterEqual(len(draft["visual_placeholders"]), 1)
self.assertGreaterEqual(len(draft["evidence_references"]), 1)
self.assertIn("unsupported_claim_warnings", draft)
body_markdown = draft["body_markdown"]
cursor = -1
for heading in section_headings:
position = body_markdown.find(f"## {heading}")
self.assertGreater(position, cursor)
cursor = position
self.assertIn("## Evidence References", body_markdown)
self.assertIn("## Visual Placeholders", body_markdown)
self.assertIn("## FAQ", body_markdown)
self.assertIn("| Step | Result |", body_markdown)
self.assertIn("[official documentation](https://example.com/docs)", body_markdown)
self.assertIn("![Existing mock](placeholder://existing/mock)", body_markdown)
drafts_response = self.client.get(
f"/api/articles/{article_id}/drafts",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, drafts_response.status_code, drafts_response.text)
self.assertEqual(1, len(drafts_response.json()["drafts"]))
draft_id = draft["id"]
draft_detail_response = self.client.get(
f"/api/articles/{article_id}/drafts/{draft_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, draft_detail_response.status_code, draft_detail_response.text)
self.assertEqual(draft["body_markdown"], draft_detail_response.json()["draft"]["body_markdown"])
def test_patch_creates_new_immutable_draft_version(self) -> None:
article_id, _ = self._prepare_article_for_draft_assembly()
section_jobs = self._start_parallel_production(article_id)
self._complete_all_sections_success(section_jobs)
first_draft = self._assemble(article_id)
patch_response = self.client.patch(
f"/api/articles/{article_id}/drafts/{first_draft['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"meta_title": "Updated metadata title",
"body_markdown": first_draft["body_markdown"] + "\n\nRevision marker.\n",
},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
second_draft = patch_response.json()["draft"]
self.assertEqual(2, second_draft["version"])
self.assertEqual(first_draft["id"], second_draft["based_on_draft_id"])
self.assertEqual("Updated metadata title", second_draft["meta_title"])
self.assertIn("Revision marker.", second_draft["body_markdown"])
first_draft_refresh = self.client.get(
f"/api/articles/{article_id}/drafts/{first_draft['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["draft"]
self.assertEqual(1, first_draft_refresh["version"])
self.assertNotIn("Revision marker.", first_draft_refresh["body_markdown"])
self.assertNotEqual(second_draft["meta_title"], first_draft_refresh["meta_title"])
list_response = self.client.get(
f"/api/articles/{article_id}/drafts",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, list_response.status_code, list_response.text)
versions = [item["version"] for item in list_response.json()["drafts"]]
self.assertEqual([2, 1], versions)
def test_assemble_fails_with_clear_conflict_when_scaffold_missing(self) -> None:
article_id, section_headings = self._prepare_article_for_draft_assembly()
section_jobs = self._start_parallel_production(article_id)
self.assertGreaterEqual(len(section_jobs), 2)
for job in section_jobs[:-1]:
completed = self._complete_job(
job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/ready.md"}],
"payload": {
"used_evidence_ids": job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": f"## {job['payload']['heading']}\n\nReady.",
},
},
)
self.assertEqual("SUCCEEDED", completed["status"])
missing_heading = section_headings[-1]
assemble_response = self.client.post(
f"/api/articles/{article_id}/draft/assemble",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(409, assemble_response.status_code, assemble_response.text)
self.assertIn("Missing successful section scaffolds", assemble_response.text)
self.assertIn(missing_heading, assemble_response.text)
def test_assemble_carries_unsupported_claim_warnings_from_scaffold_attempts(self) -> None:
article_id, _ = self._prepare_article_for_draft_assembly()
section_jobs = self._start_parallel_production(article_id)
self.assertGreaterEqual(len(section_jobs), 1)
first_job = section_jobs[0]
failed = self._complete_job(
first_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/failed.md"}],
"payload": {
"used_evidence_ids": first_job["payload"]["used_evidence_ids"],
"unsupported_claims": [
{"claim_text": "Unverified migration metric", "risk_level": "high"}
],
"draft_markdown": "Content with unsupported claim",
},
},
)
self.assertEqual("FAILED", failed["status"])
retry_response = self.client.post(
f"/api/agent-jobs/{first_job['id']}/retry",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(201, retry_response.status_code, retry_response.text)
retry_job = retry_response.json()["job"]
retry_completed = self._complete_job(
retry_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/retry.md"}],
"payload": {
"used_evidence_ids": retry_job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": f"## {retry_job['payload']['heading']}\n\nClean section.",
},
},
)
self.assertEqual("SUCCEEDED", retry_completed["status"])
for job in section_jobs[1:]:
self._complete_job(
job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/ok.md"}],
"payload": {
"used_evidence_ids": job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": f"## {job['payload']['heading']}\n\nReady.",
},
},
)
draft = self._assemble(article_id)
warnings = draft["unsupported_claim_warnings"]
self.assertTrue(any("Unverified migration metric" in warning for warning in warnings))
self.assertIn("## Unsupported Claim Warnings", draft["body_markdown"])
def test_draft_endpoints_return_404_for_missing_resources(self) -> None:
random_article_id = uuid4()
list_response = self.client.get(
f"/api/articles/{random_article_id}/drafts",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(404, list_response.status_code, list_response.text)
article_id, _ = self._prepare_article_for_draft_assembly()
random_draft_id = uuid4()
detail_response = self.client.get(
f"/api/articles/{article_id}/drafts/{random_draft_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(404, detail_response.status_code, detail_response.text)
patch_response = self.client.patch(
f"/api/articles/{article_id}/drafts/{random_draft_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"title": "Will fail"},
)
self.assertEqual(404, patch_response.status_code, patch_response.text)
def _assemble(self, article_id: str) -> dict[str, Any]:
response = self.client.post(
f"/api/articles/{article_id}/draft/assemble",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, response.status_code, response.text)
return response.json()["draft"]
def _complete_all_sections_success(self, section_jobs: list[dict[str, Any]]) -> None:
for index, job in enumerate(section_jobs, start=1):
completed = self._complete_job(
job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": f"outputs/{index}.md"}],
"payload": {
"used_evidence_ids": job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": f"## {job['payload']['heading']}\n\nSection {index}.",
},
},
)
self.assertEqual("SUCCEEDED", completed["status"])
def _complete_job(self, job_id: str, *, output: dict[str, Any]) -> dict[str, Any]:
response = self.client.post(
f"/internal/agent-jobs/{job_id}/complete",
json={
"workspace_path": f"/tmp/{job_id}",
"stdout": "fake section scaffolding runner\n",
"stderr": "",
"exit_code": 0,
"duration_ms": 1,
"output": output,
},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["job"]
def _start_parallel_production(self, article_id: str) -> list[dict[str, Any]]:
start_response = self.client.post(
f"/api/articles/{article_id}/draft/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(202, start_response.status_code, start_response.text)
jobs = start_response.json()["jobs"]
return [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"]
def _prepare_article_for_draft_assembly(self) -> tuple[str, list[str]]:
article_id, section_headings = self._create_article_with_approved_plan()
self._ensure_evidence_ready(article_id)
return article_id, section_headings
def _ensure_evidence_ready(self, article_id: str) -> None:
research_response = self.client.post(
f"/api/articles/{article_id}/research/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, research_response.status_code, research_response.text)
evidence_response = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
self.assertFalse(evidence_response.json()["insufficient_evidence_reasons"])
def _create_article_with_approved_plan(self) -> tuple[str, list[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": "Assemble draft from section scaffolds with versions.",
"working_title": "Draft Assembly With Versions",
"content_type": "longform_guide",
"primary_keyword": "draft assembly editor preview",
},
).json()["article"]
article_id = article["id"]
questions = self.client.post(
f"/api/articles/{article_id}/boundary-questions/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["questions"]
for question in questions:
if question["is_required"]:
patch_response = self.client.patch(
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"answer": f"Answer for {question['category']}"},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
submit_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/submit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, submit_response.status_code, submit_response.text)
plan_response = self.client.post(
f"/api/articles/{article_id}/plan/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, plan_response.status_code, plan_response.text)
plan = plan_response.json()["plan"]
self.assertGreaterEqual(len(plan["sections"]), 1)
approve_response = self.client.post(
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, approve_response.status_code, approve_response.text)
return article_id, [section["heading"] for section in plan["sections"]]
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,357 @@
from __future__ import annotations
import base64
import os
import subprocess
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_ADMIN_EMAIL = "admin@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class EndToEndDemoStackSmokePublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
self.object_root = Path(self.tmp_dir.name) / "objects"
self.demo_repo_path = Path(self.tmp_dir.name) / "demo-site.git"
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(self.object_root)
os.environ["PIPELINE_DEMO_MODE"] = "1"
os.environ["PIPELINE_DEMO_PUBLISH_REPO_PATH"] = str(self.demo_repo_path)
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'demo-stack-smoke.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)
os.environ.pop("PIPELINE_DEMO_MODE", None)
os.environ.pop("PIPELINE_DEMO_PUBLISH_REPO_PATH", None)
self.tmp_dir.cleanup()
def test_demo_happy_path_reaches_publish_commit_created_with_failure_retry(self) -> None:
site_id = self._fetch_first_site_id()
article_id = self._create_article(site_id)
self._generate_submit_boundary_answers(article_id)
plan = self._generate_and_approve_plan(article_id)
self.assertGreaterEqual(len(plan["sections"]), 1)
research = self.client.post(
f"/api/articles/{article_id}/research/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, research.status_code, research.text)
research_listing = self.client.get(
f"/api/articles/{article_id}/research",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, research_listing.status_code, research_listing.text)
manifests = research_listing.json()["manifests"]
self.assertTrue(manifests)
self._assert_research_artifacts_exist(manifests)
evidence = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, evidence.status_code, evidence.text)
self.assertEqual("EVIDENCE_MATRIX_READY", evidence.json()["article"]["status"])
self._approve_all_evidence(article_id, evidence.json()["evidence"])
start_draft = self.client.post(
f"/api/articles/{article_id}/draft/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(202, start_draft.status_code, start_draft.text)
started_jobs = start_draft.json()["jobs"]
section_jobs = [job for job in started_jobs if job["job_type"] == "SECTION_SCAFFOLD"]
self.assertTrue(section_jobs)
failed_jobs = [job for job in section_jobs if job["status"] == "FAILED"]
self.assertTrue(failed_jobs, "Demo path must include visible failure before retry.")
retry = self.client.post(
f"/api/agent-jobs/{failed_jobs[0]['id']}/retry",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(201, retry.status_code, retry.text)
self.assertEqual("SUCCEEDED", retry.json()["job"]["status"])
assemble = self.client.post(
f"/api/articles/{article_id}/draft/assemble",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, assemble.status_code, assemble.text)
draft = assemble.json()["draft"]
assets = self.client.post(
f"/api/articles/{article_id}/assets/generate-specs",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, assets.status_code, assets.text)
generated_assets = assets.json()["assets"]
self.assertTrue(generated_assets)
self.assertTrue(
any(asset["asset_type"] in {"diagram", "table", "flowchart", "comparison_matrix", "architecture_diagram"} for asset in generated_assets)
)
self._approve_assets(article_id, generated_assets)
seo = self.client.post(
f"/api/articles/{article_id}/seo/review",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, seo.status_code, seo.text)
language = self.client.post(
f"/api/articles/{article_id}/language/review",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, language.status_code, language.text)
self._accept_all_review_suggestions(article_id, seo.json()["report"]["issues"], kind="seo")
self._accept_all_review_suggestions(article_id, language.json()["report"]["issues"], kind="language")
issues = self.client.get(
f"/api/articles/{article_id}/final-review/issues",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, issues.status_code, issues.text)
self.assertEqual(0, issues.json()["unresolved_count"])
drafts = self.client.get(
f"/api/articles/{article_id}/drafts",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, drafts.status_code, drafts.text)
latest_draft = max(drafts.json()["drafts"], key=lambda item: item["version"])
approval = self.client.post(
f"/api/articles/{article_id}/final-approval",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"draft_version": latest_draft["version"],
"publishing_settings": {
"content_path": "/guides/task-019-demo-stack",
"author": "Demo Editor",
"publishing_mode": "MANUAL",
"frontmatter": {
"title": latest_draft["title"],
"category": "Demo",
},
},
},
)
self.assertEqual(200, approval.status_code, approval.text)
self.assertEqual("PUBLISH_DRY_RUN_REQUIRED", approval.json()["article"]["status"])
dry_run = self.client.post(
f"/api/articles/{article_id}/publishing/dry-run",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, dry_run.status_code, dry_run.text)
self.assertTrue(dry_run.json()["content_shape_valid"])
self.assertEqual("PUBLISH_COMMIT_READY", dry_run.json()["article"]["status"])
commit = self.client.post(
f"/api/articles/{article_id}/publishing/create-commit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, commit.status_code, commit.text)
publish_commit = commit.json()["publish_commit"]
self.assertEqual("PUBLISH_COMMIT_CREATED", publish_commit["status"])
self.assertEqual("PUBLISH_COMMIT_CREATED", commit.json()["article"]["status"])
self.assertEqual(str(self.demo_repo_path.resolve()), publish_commit["repository_url"])
self._assert_commit_payload_valid(publish_commit)
self._assert_timeline_contains_major_actions(article_id)
def _fetch_first_site_id(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)
site = site_response.json()[0]["site"]
self.assertEqual(
str(self.demo_repo_path.resolve()),
site["publishing_rules"]["repository_url"],
)
return site["id"]
def _create_article(self, site_id: str) -> str:
article_response = self.client.post(
"/api/articles",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"target_site_id": site_id,
"brief_description": "Demo-stack shortest public happy path toward publish flow.",
"working_title": "Task 019 Demo Stack Smoke",
"content_type": "longform_guide",
"primary_keyword": "demo stack smoke path",
},
)
self.assertEqual(201, article_response.status_code, article_response.text)
return article_response.json()["article"]["id"]
def _generate_submit_boundary_answers(self, article_id: str) -> None:
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)
questions = questions_response.json()["questions"]
for question in questions:
if question["is_required"]:
patch_response = self.client.patch(
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"answer": f"Answer for {question['category']}"},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
submit_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/submit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, submit_response.status_code, submit_response.text)
def _generate_and_approve_plan(self, article_id: str) -> dict[str, object]:
plan_response = self.client.post(
f"/api/articles/{article_id}/plan/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, plan_response.status_code, plan_response.text)
plan = plan_response.json()["plan"]
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 plan
def _approve_assets(self, article_id: str, assets: list[dict[str, object]]) -> None:
fake_image = base64.b64encode(b"fake-image-bytes").decode("utf-8")
for asset in assets:
upload = self.client.post(
f"/api/articles/{article_id}/assets/{asset['id']}/upload",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"filename": f"{asset['id']}.png",
"content_base64": fake_image,
"content_type": "image/png",
},
)
self.assertEqual(200, upload.status_code, upload.text)
approve = self.client.post(
f"/api/articles/{article_id}/assets/{asset['id']}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, approve.status_code, approve.text)
def _approve_all_evidence(self, article_id: str, evidence_rows: list[dict[str, object]]) -> None:
for evidence in evidence_rows:
response = self.client.patch(
f"/api/articles/{article_id}/evidence/{evidence['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"review_status": "APPROVED"},
)
self.assertEqual(200, response.status_code, response.text)
def _accept_all_review_suggestions(
self,
article_id: str,
issues: list[dict[str, object]],
*,
kind: str,
) -> None:
for issue in issues:
suggestion_id = issue["suggestion_id"]
endpoint = f"/api/articles/{article_id}/{kind}/suggestions/{suggestion_id}/accept"
response = self.client.post(
endpoint,
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, response.status_code, response.text)
def _assert_research_artifacts_exist(self, manifests: list[dict[str, object]]) -> None:
for manifest in manifests:
artifacts = manifest.get("artifacts", [])
self.assertTrue(artifacts)
for artifact in artifacts:
object_key = artifact["object_key"]
path = self.object_root / object_key
self.assertTrue(path.exists(), f"Missing research artifact: {path}")
def _assert_commit_payload_valid(self, publish_commit: dict[str, object]) -> None:
manifest = publish_commit["content_bundle_manifest"]
content_path = manifest["content"]["path"]
assets = manifest["assets"]
commit_sha = publish_commit["commit_sha"]
markdown = self._git_show(commit_sha, content_path)
self.assertIn("---", markdown)
self.assertIn("title:", markdown)
self.assertIn("author:", markdown)
self.assertIn("##", markdown)
self.assertTrue(assets)
for asset in assets:
target = asset["target_path"]
blob = self._git_show(commit_sha, target)
self.assertTrue(blob)
def _assert_timeline_contains_major_actions(self, article_id: str) -> None:
detail = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, detail.status_code, detail.text)
timeline = detail.json()["timeline"]
event_types = [event["event_type"] for event in timeline]
for required in (
"ARTICLE_CREATED",
"BOUNDARY_QUESTIONS_GENERATED",
"BOUNDARY_ANSWERS_SUBMITTED",
"PLAN_APPROVED",
"EVIDENCE_MATRIX_READY",
"PARALLEL_PRODUCTION_STARTED",
"AGENT_JOB_FAILED",
"AGENT_JOB_RETRIED",
"DRAFT_ASSEMBLED",
"SEO_REVIEW_COMPLETED",
"LANGUAGE_REVIEW_COMPLETED",
"FINAL_APPROVAL_GRANTED",
"PUBLISH_DRY_RUN_SUCCEEDED",
"PUBLISH_COMMIT_CREATED",
):
self.assertIn(required, event_types)
def _git_show(self, commit_sha: str, relative_path: str) -> str:
result = subprocess.run(
["git", "--git-dir", str(self.demo_repo_path), "show", f"{commit_sha}:{relative_path}"],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(0, result.returncode, result.stderr or result.stdout)
return result.stdout
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,193 @@
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 EvidenceMatrixPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects")
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'evidence.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_insufficient_evidence_returns_to_plan_revision_and_blocks_draft(self) -> None:
article_id = self._create_researched_article("insufficient evidence coverage")
evidence_response = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
body = evidence_response.json()
self.assertEqual("PLAN_REVISION_REQUIRED", body["article"]["status"])
self.assertTrue(body["insufficient_evidence_reasons"])
unsupported = [
claim
for claim in body["claims"]
if claim["support_status"] == "UNSUPPORTED"
]
self.assertTrue(unsupported)
self.assertTrue(
any(claim["risk_level"] == "high" for claim in unsupported)
)
draft_response = self.client.post(
f"/api/articles/{article_id}/draft/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(409, draft_response.status_code, draft_response.text)
final_response = self.client.post(
f"/api/articles/{article_id}/final/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(409, final_response.status_code, final_response.text)
evidence_item = body["evidence"][0]
patch_response = self.client.patch(
f"/api/articles/{article_id}/evidence/{evidence_item['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"review_status": "REJECTED"},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
self.assertEqual("REJECTED", patch_response.json()["evidence"]["review_status"])
def test_sufficient_evidence_maps_claims_and_allows_draft(self) -> None:
article_id = self._create_researched_article("sufficient evidence coverage")
evidence_response = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
body = evidence_response.json()
self.assertEqual("EVIDENCE_MATRIX_READY", body["article"]["status"])
self.assertFalse(body["insufficient_evidence_reasons"])
self.assertTrue(body["evidence"])
self.assertTrue(body["claims"])
self.assertTrue(
all(claim["evidence_item_ids"] for claim in body["claims"])
)
first_evidence = body["evidence"][0]
self.assertTrue(first_evidence["source_url"])
self.assertTrue(first_evidence["source_title"])
self.assertTrue(first_evidence["source_type"])
self.assertTrue(first_evidence["summary"])
self.assertGreaterEqual(first_evidence["source_quality_score"], 0)
self.assertTrue(first_evidence["retrieved_at"])
self.assertTrue(first_evidence["artifact_manifest_id"])
add_response = self.client.post(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"source_title": "Manual source",
"source_url": "https://manual.example/source",
"source_type": "manual",
"summary": "Manual evidence note",
"source_quality_score": 0.74,
},
)
self.assertEqual(201, add_response.status_code, add_response.text)
manual_evidence = add_response.json()["evidence"]
self.assertEqual("PENDING", manual_evidence["review_status"])
delete_response = self.client.delete(
f"/api/articles/{article_id}/evidence/{manual_evidence['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, delete_response.status_code, delete_response.text)
refreshed = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()
self.assertNotIn(
manual_evidence["id"],
[item["id"] for item in refreshed["evidence"]],
)
draft_response = self.client.post(
f"/api/articles/{article_id}/draft/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(202, draft_response.status_code, draft_response.text)
def _create_researched_article(self, brief_suffix: str) -> str:
site = self.client.get(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()[0]["site"]
article_id = self.client.post(
"/api/articles",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"target_site_id": site["id"],
"brief_description": f"Build an article with {brief_suffix}.",
"working_title": "Evidence Matrix Article",
"content_type": "longform_guide",
"primary_keyword": brief_suffix,
},
).json()["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"]:
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.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"]
self.client.post(
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.client.post(
f"/api/articles/{article_id}/research/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
return article_id
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,427 @@
from __future__ import annotations
import base64
import os
import sys
import tempfile
import unittest
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi.testclient import TestClient
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
from src.application.seed_data import seed_reference_data # noqa: E402
from src.infrastructure.repositories import open_backend_repository # noqa: E402
from src.presentation.dependencies import get_repository # noqa: E402
from src.presentation.main import app # noqa: E402
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_ADMIN_EMAIL = "admin@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class FinalApprovalGatePublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects")
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'final-approval-gate.db'}"
self.repository = open_backend_repository(dsn)
self.repository.setup()
seed_reference_data(self.repository)
app.dependency_overrides[get_repository] = lambda: self.repository
self.client = TestClient(app)
def tearDown(self) -> None:
app.dependency_overrides.clear()
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
self.tmp_dir.cleanup()
def test_high_risk_unsupported_claims_block_final_approval(self) -> None:
article_id, draft = self._prepare_article_ready_for_final_approval(
include_high_risk_unsupported=True,
)
response = self.client.post(
f"/api/articles/{article_id}/final-approval",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json=self._final_approval_payload(draft_version=draft["version"]),
)
self.assertEqual(409, response.status_code, response.text)
self.assertIn("high-risk unsupported claims", response.text.lower())
def test_final_approval_requires_exact_draft_version(self) -> None:
article_id, draft = self._prepare_article_ready_for_final_approval()
response = self.client.post(
f"/api/articles/{article_id}/final-approval",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json=self._final_approval_payload(draft_version=draft["version"] + 1),
)
self.assertEqual(409, response.status_code, response.text)
self.assertIn("latest draft version", response.text.lower())
def test_missing_required_publishing_settings_block_final_approval(self) -> None:
article_id, draft = self._prepare_article_ready_for_final_approval()
response = self.client.post(
f"/api/articles/{article_id}/final-approval",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"draft_version": draft["version"],
"publishing_settings": {
"content_path": "",
"author": "",
"publishing_mode": "",
"frontmatter": {},
},
},
)
self.assertEqual(409, response.status_code, response.text)
self.assertIn("required publishing settings are missing", response.text.lower())
def test_unapproved_required_assets_block_final_approval(self) -> None:
article_id, draft = self._prepare_article_ready_for_final_approval(approve_assets=False)
response = self.client.post(
f"/api/articles/{article_id}/final-approval",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json=self._final_approval_payload(draft_version=draft["version"]),
)
self.assertEqual(409, response.status_code, response.text)
self.assertIn("required assets", response.text.lower())
def test_successful_final_approval_writes_event_and_moves_to_publish_dry_run_required(self) -> None:
article_id, draft = self._prepare_article_ready_for_final_approval()
actor = self.client.get(
"/api/me",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["user"]
payload = self._final_approval_payload(draft_version=draft["version"])
response = self.client.post(
f"/api/articles/{article_id}/final-approval",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json=payload,
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertEqual("PUBLISH_DRY_RUN_REQUIRED", body["article"]["status"])
self.assertTrue(body["checklist"]["can_approve"])
self.assertFalse(body["checklist"]["blockers"])
detail = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()
events = [
event
for event in detail["workflow_events"]
if event["event_type"] == "FINAL_APPROVAL_GRANTED"
]
self.assertTrue(events, "Expected FINAL_APPROVAL_GRANTED event")
latest = events[-1]
self.assertEqual(actor["id"], latest["actor_user_id"])
self.assertTrue(latest["created_at"])
self.assertEqual(draft["version"], latest["payload"]["draft_version"])
self.assertEqual(payload["publishing_settings"], latest["payload"]["publishing_settings"])
def test_revision_request_writes_event_and_moves_article_to_final_revision_required(self) -> None:
article_id, draft = self._prepare_article_ready_for_final_approval()
actor = self.client.get(
"/api/me",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["user"]
response = self.client.post(
f"/api/articles/{article_id}/final-revision-request",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"reason": "Need factual clarification in section 2.",
"draft_version": draft["version"],
},
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertEqual("FINAL_REVISION_REQUIRED", body["article"]["status"])
detail = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()
events = [
event
for event in detail["workflow_events"]
if event["event_type"] == "FINAL_REVISION_REQUESTED"
]
self.assertTrue(events, "Expected FINAL_REVISION_REQUESTED event")
latest = events[-1]
self.assertEqual(actor["id"], latest["actor_user_id"])
self.assertEqual("Need factual clarification in section 2.", latest["payload"]["reason"])
self.assertEqual(draft["version"], latest["payload"]["draft_version"])
def test_checklist_endpoint_returns_blockers_and_completed_items(self) -> None:
article_id, _ = self._prepare_article_ready_for_final_approval(approve_assets=False)
response = self.client.get(
f"/api/articles/{article_id}/final-approval/checklist",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, response.status_code, response.text)
checklist = response.json()
self.assertFalse(checklist["can_approve"])
self.assertTrue(checklist["blockers"])
self.assertTrue(checklist["completed_items"])
self.assertTrue(any(item["completed"] for item in checklist["items"]))
self.assertTrue(any(not item["completed"] for item in checklist["items"]))
def _final_approval_payload(self, *, draft_version: int) -> dict[str, Any]:
return {
"draft_version": draft_version,
"publishing_settings": {
"content_path": "/guides/final-approval-gate",
"author": "Editorial Team",
"publishing_mode": "MANUAL",
"frontmatter": {
"category": "Guides",
"tags": ["workflow", "approval"],
},
},
}
def _prepare_article_ready_for_final_approval(
self,
*,
include_high_risk_unsupported: bool = False,
approve_assets: bool = True,
) -> tuple[str, dict[str, Any]]:
article_id = self._create_article_with_approved_plan()
self._ensure_evidence_ready(article_id)
self._approve_all_evidence(article_id)
section_jobs = self._start_parallel_production(article_id)
self.assertGreaterEqual(len(section_jobs), 1)
first_job = section_jobs[0]
if include_high_risk_unsupported:
failed = self._complete_job(
first_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/high-risk.md"}],
"payload": {
"used_evidence_ids": first_job["payload"]["used_evidence_ids"],
"unsupported_claims": [
{
"claim_text": "Unverified migration benchmark for market leadership.",
"risk_level": "high",
}
],
"draft_markdown": "Section draft with high-risk unsupported claim.",
},
},
)
self.assertEqual("FAILED", failed["status"])
retry_response = self.client.post(
f"/api/agent-jobs/{first_job['id']}/retry",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(201, retry_response.status_code, retry_response.text)
retry_job = retry_response.json()["job"]
retried = self._complete_job(
retry_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/retry-clean.md"}],
"payload": {
"used_evidence_ids": retry_job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": (
f"## {retry_job['payload']['heading']}\n\n"
"Clean retry section with [internal guide](/guides/internal)."
),
},
},
)
self.assertEqual("SUCCEEDED", retried["status"])
else:
completed = self._complete_job(
first_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/section-1.md"}],
"payload": {
"used_evidence_ids": first_job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": (
f"## {first_job['payload']['heading']}\n\n"
"Use [internal guide](/guides/internal) for implementation details."
),
},
},
)
self.assertEqual("SUCCEEDED", completed["status"])
for index, job in enumerate(section_jobs[1:], start=2):
completed = self._complete_job(
job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": f"outputs/section-{index}.md"}],
"payload": {
"used_evidence_ids": job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": f"## {job['payload']['heading']}\n\nSection {index} ready.",
},
},
)
self.assertEqual("SUCCEEDED", completed["status"])
assemble_response = self.client.post(
f"/api/articles/{article_id}/draft/assemble",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, assemble_response.status_code, assemble_response.text)
draft = assemble_response.json()["draft"]
assets_response = self.client.post(
f"/api/articles/{article_id}/assets/generate-specs",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, assets_response.status_code, assets_response.text)
assets = assets_response.json()["assets"]
self.assertTrue(assets)
if approve_assets:
for asset in assets:
upload_response = self.client.post(
f"/api/articles/{article_id}/assets/{asset['id']}/upload",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"filename": f"{asset['id']}.png",
"content_base64": base64.b64encode(b"asset-binary").decode("utf-8"),
"content_type": "image/png",
},
)
self.assertEqual(200, upload_response.status_code, upload_response.text)
approve_response = self.client.post(
f"/api/articles/{article_id}/assets/{asset['id']}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, approve_response.status_code, approve_response.text)
return article_id, draft
def _approve_all_evidence(self, article_id: str) -> None:
evidence_response = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
evidence = evidence_response.json()["evidence"]
self.assertTrue(evidence)
for item in evidence:
patch_response = self.client.patch(
f"/api/articles/{article_id}/evidence/{item['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"review_status": "APPROVED"},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
def _complete_job(self, job_id: str, *, output: dict[str, Any]) -> dict[str, Any]:
response = self.client.post(
f"/internal/agent-jobs/{job_id}/complete",
json={
"workspace_path": f"/tmp/{job_id}",
"stdout": "fake section scaffolding runner\n",
"stderr": "",
"exit_code": 0,
"duration_ms": 1,
"output": output,
},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["job"]
def _start_parallel_production(self, article_id: str) -> list[dict[str, Any]]:
response = self.client.post(
f"/api/articles/{article_id}/draft/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(202, response.status_code, response.text)
return [job for job in response.json()["jobs"] if job["job_type"] == "SECTION_SCAFFOLD"]
def _ensure_evidence_ready(self, article_id: str) -> None:
research_response = self.client.post(
f"/api/articles/{article_id}/research/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, research_response.status_code, research_response.text)
evidence_response = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
def _create_article_with_approved_plan(self) -> str:
site = self.client.get(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()[0]["site"]
article = self.client.post(
"/api/articles",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"target_site_id": site["id"],
"brief_description": "Validate final approval gate checklist and transitions.",
"working_title": "Final Approval Gate",
"content_type": "longform_guide",
"primary_keyword": "final approval gate",
},
).json()["article"]
article_id = article["id"]
questions = self.client.post(
f"/api/articles/{article_id}/boundary-questions/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["questions"]
for question in questions:
if question["is_required"]:
patch_response = self.client.patch(
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"answer": f"Answer for {question['category']}"},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
submit_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/submit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, submit_response.status_code, submit_response.text)
plan_response = self.client.post(
f"/api/articles/{article_id}/plan/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, plan_response.status_code, plan_response.text)
plan = plan_response.json()["plan"]
self.assertGreaterEqual(len(plan["sections"]), 1)
approve_response = self.client.post(
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, approve_response.status_code, approve_response.text)
return article_id
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,493 @@
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi.testclient import TestClient
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
from src.application.seed_data import seed_reference_data # noqa: E402
from src.domain.contracts import AgentJobStatus, AgentJobType, PublishingStatus # noqa: E402
from src.infrastructure.repositories import open_backend_repository # noqa: E402
from src.presentation.dependencies import get_repository # noqa: E402
from src.presentation.main import app # noqa: E402
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_ADMIN_EMAIL = "admin@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class ObservabilityRetryCancelAuditPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects")
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'observability.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_failed_job_appears_in_article_history_with_retry_eligibility(self) -> None:
article_id = self._prepare_article_for_parallel_production()
created_jobs = self._start_parallel_production(article_id)
section_job = [job for job in created_jobs if job["job_type"] == "SECTION_SCAFFOLD"][0]
failed_job = self._complete_job(
section_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/section-failed.md"}],
"payload": {
"unsupported_claims": [
{"claim_text": "Unverified claim", "risk_level": "high"}
]
},
},
)
self.assertEqual("FAILED", failed_job["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)
article_jobs = detail_response.json()["agent_jobs"]
history_failed_job = [job for job in article_jobs if job["id"] == failed_job["id"]][0]
self.assertIn("retry_eligible", history_failed_job)
self.assertTrue(history_failed_job["retry_eligible"])
self.assertIsNone(history_failed_job["retry_block_reason"])
def test_article_timeline_mixes_user_system_and_agent_events_in_order(self) -> None:
article_id = self._prepare_article_for_parallel_production()
section_job = self._start_parallel_production(article_id)[0]
failed = self._complete_job(
section_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/section-failed.md"}],
"payload": {
"unsupported_claims": [{"claim_text": "unsupported claim", "risk_level": "high"}]
},
},
)
self.assertEqual("FAILED", failed["status"])
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)
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)
timeline = detail_response.json()["timeline"]
self.assertGreaterEqual(len(timeline), 3)
created_at_values = [entry["created_at"] for entry in timeline]
self.assertEqual(created_at_values, sorted(created_at_values))
sources = {entry["source"] for entry in timeline}
self.assertIn("USER", sources)
self.assertIn("SYSTEM", sources)
self.assertIn("AGENT", sources)
failed_job_entry = [
entry
for entry in timeline
if entry["entry_type"] == "AGENT_JOB"
and entry.get("job_id") == failed["id"]
][0]
self.assertTrue(failed_job_entry["retry_eligible"])
self.assertEqual("FAILED", failed_job_entry["job_status"])
def test_sensitive_logs_are_redacted_and_editor_sees_only_safe_failure_summary(self) -> None:
article_id = self._prepare_article_for_parallel_production()
section_job = self._start_parallel_production(article_id)[0]
failed_job = self._complete_job(
section_job["id"],
exit_code=1,
stdout=(
"token=topsecret123 password=hunter2 "
"Authorization: Bearer very-secret-token\n"
),
stderr="api_key=prod-key-123\n",
output={
"status": "FAILED",
"error_message": "runner failed with sk-1234567890abcdef",
"payload": {"last_successful_step": "outline written"},
},
)
self.assertEqual("FAILED", failed_job["status"])
admin_detail = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
).json()
editor_detail = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()
admin_job = [job for job in admin_detail["agent_jobs"] if job["id"] == failed_job["id"]][0]
editor_job = [job for job in editor_detail["agent_jobs"] if job["id"] == failed_job["id"]][0]
self.assertIn("[REDACTED]", admin_job["stdout"])
self.assertIn("[REDACTED]", admin_job["stderr"])
self.assertNotIn("topsecret123", admin_job["stdout"])
self.assertNotIn("hunter2", admin_job["stdout"])
self.assertNotIn("very-secret-token", admin_job["stdout"])
self.assertNotIn("prod-key-123", admin_job["stderr"])
self.assertNotIn("sk-1234567890abcdef", admin_job["error_message"] or "")
self.assertEqual("", editor_job["stdout"])
self.assertEqual("", editor_job["stderr"])
self.assertIn("failed", editor_job["safe_failure_summary"].lower())
self.assertNotIn("topsecret123", editor_job["safe_failure_summary"])
def test_retry_eligibility_and_cancel_eligibility_follow_policy(self) -> None:
article_id = self._prepare_article_for_parallel_production()
jobs = self._start_parallel_production(article_id)
section_job = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"][0]
section_failed = self._complete_job(
section_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/section-failed.md"}],
"payload": {
"unsupported_claims": [{"claim_text": "unsupported claim", "risk_level": "high"}]
},
},
)
self.assertEqual("FAILED", section_failed["status"])
now = datetime.now(UTC)
evidence_job = self.repository.agent_jobs.create(
article_id=UUID(article_id),
parent_job_id=None,
attempt=1,
job_type=AgentJobType.EVIDENCE_MATRIX,
agent_profile="manual-test",
status=AgentJobStatus.QUEUED,
input_files=[{"path": "inputs/evidence.json"}],
queued_at=now,
)
evidence_failed = self.repository.agent_jobs.complete(
job_id=evidence_job.id,
status=AgentJobStatus.FAILED,
workspace_path="/tmp/evidence",
output_files=[],
payload={"phase": "collect"},
error_category=None,
error_message="temporary failure",
stdout="",
stderr="",
exit_code=1,
duration_ms=2,
finished_at=now,
)
self.assertEqual(AgentJobStatus.FAILED, evidence_failed.status)
queued_section = [job for job in jobs if job["id"] != section_job["id"]][0]
detail = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
).json()
failed_section_view = [job for job in detail["agent_jobs"] if job["id"] == section_failed["id"]][0]
evidence_view = [job for job in detail["agent_jobs"] if job["id"] == str(evidence_failed.id)][0]
queued_view = [job for job in detail["agent_jobs"] if job["id"] == queued_section["id"]][0]
self.assertTrue(failed_section_view["retry_eligible"])
self.assertIsNone(failed_section_view["retry_block_reason"])
self.assertFalse(failed_section_view["cancel_eligible"])
self.assertFalse(evidence_view["retry_eligible"])
self.assertIn("not supported", evidence_view["retry_block_reason"].lower())
self.assertFalse(evidence_view["cancel_eligible"])
self.assertFalse(queued_view["retry_eligible"])
self.assertTrue(queued_view["cancel_eligible"])
def test_cancelling_running_or_queued_job_prevents_article_state_mutation(self) -> None:
article_id = self._prepare_article_for_parallel_production()
jobs = self._start_parallel_production(article_id)
first_job = jobs[0]
cancel_response = self.client.post(
f"/api/agent-jobs/{first_job['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"])
complete_response = self.client.post(
f"/internal/agent-jobs/{first_job['id']}/complete",
json={
"workspace_path": f"/tmp/{first_job['id']}",
"stdout": "completion after cancellation\n",
"stderr": "",
"exit_code": 0,
"duration_ms": 2,
"output": {
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/cancelled.md"}],
"payload": {"draft_markdown": "should never apply"},
},
},
)
self.assertEqual(200, complete_response.status_code, complete_response.text)
self.assertEqual("CANCELLED", complete_response.json()["job"]["status"])
detail = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()
self.assertEqual("PARALLEL_PRODUCTION_RUNNING", detail["article"]["status"])
cancelled_view = [job for job in detail["agent_jobs"] if job["id"] == first_job["id"]][0]
self.assertEqual("CANCELLED", cancelled_view["status"])
def test_publish_commit_retry_blocked_after_publish_commit_exists(self) -> None:
article = self.client.post(
"/api/articles",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"target_site_id": self._first_site_id(),
"brief_description": "Publish retry policy test",
"working_title": "Publish Retry Policy",
"content_type": "longform_guide",
"primary_keyword": "publish retry policy",
},
).json()["article"]
article_id = article["id"]
now = datetime.now(UTC)
publish_job = self.repository.agent_jobs.create(
article_id=UUID(article_id),
parent_job_id=None,
attempt=1,
job_type=AgentJobType.PUBLISH_COMMIT,
agent_profile="manual-publish-commit",
status=AgentJobStatus.QUEUED,
input_files=[{"path": "inputs/publish.json"}],
queued_at=now,
)
publish_job = self.repository.agent_jobs.complete(
job_id=publish_job.id,
status=AgentJobStatus.FAILED,
workspace_path="/tmp/publish",
output_files=[],
payload={"stage": "git push"},
error_category=None,
error_message="git push failed",
stdout="",
stderr="",
exit_code=1,
duration_ms=5,
finished_at=now,
)
self.repository.publish_commits.create(
article_id=UUID(article_id),
target_site_id=UUID(self._first_site_id()),
repository_url="git@example.com/site.git",
branch="main",
commit_sha="abc123",
content_bundle_manifest={"validation": {"label": "ok"}},
status=PublishingStatus.PUBLISH_COMMIT_CREATED,
deployment_status=None,
created_at=now,
)
retry_response = self.client.post(
f"/api/agent-jobs/{publish_job.id}/retry",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(409, retry_response.status_code, retry_response.text)
self.assertIn("already exists", retry_response.text.lower())
detail = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
).json()
publish_job_view = [job for job in detail["agent_jobs"] if job["id"] == str(publish_job.id)][0]
self.assertFalse(publish_job_view["retry_eligible"])
self.assertIn("already exists", publish_job_view["retry_block_reason"].lower())
def test_script_config_changes_are_audit_visible_with_diff_and_rollback_target(self) -> None:
site_id = self._first_site_id()
first = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json={
"publishing_yaml": "target: demo\n",
"transform_script": "export function transformArticle(a){return a;}\n",
"diff": {"summary": "v1 create"},
"activate": True,
},
)
self.assertEqual(201, first.status_code, first.text)
first_version = first.json()["version"]
second = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json={
"publishing_yaml": "target: demo\nbranch: main\n",
"transform_script": "export function transformArticle(a){return {...a, changed:true};}\n",
"diff": {"summary": "v2 create"},
"rollback_target_version_id": first_version["id"],
"activate": True,
},
)
self.assertEqual(201, second.status_code, second.text)
second_version = second.json()["version"]
rollback = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions/{first_version['id']}/rollback",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(200, rollback.status_code, rollback.text)
audit_response = self.client.get(
f"/api/sites/{site_id}/publishing-config/audit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(200, audit_response.status_code, audit_response.text)
events = audit_response.json()["events"]
self.assertGreaterEqual(len(events), 3)
for event in events:
self.assertIn("diff", event)
self.assertIn("rollback_target_version_id", event)
rollback_events = [event for event in events if event["event_type"] == "SCRIPT_CONFIG_VERSION_ROLLBACK"]
self.assertEqual(1, len(rollback_events))
rollback_event = rollback_events[0]
self.assertEqual(second_version["id"], rollback_event["rollback_target_version_id"])
def _start_parallel_production(self, article_id: str) -> list[dict[str, Any]]:
response = self.client.post(
f"/api/articles/{article_id}/draft/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(202, response.status_code, response.text)
return response.json()["jobs"]
def _complete_job(
self,
job_id: str,
*,
output: dict[str, Any],
stdout: str = "fake section scaffolding runner\n",
stderr: str = "",
exit_code: int = 0,
) -> dict[str, Any]:
response = self.client.post(
f"/internal/agent-jobs/{job_id}/complete",
json={
"workspace_path": f"/tmp/{job_id}",
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
"duration_ms": 2,
"output": output,
},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["job"]
def _first_site_id(self) -> str:
response = self.client.get(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()[0]["site"]["id"]
def _prepare_article_for_parallel_production(self) -> str:
article_id = self._create_article_with_approved_plan()
research_response = self.client.post(
f"/api/articles/{article_id}/research/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, research_response.status_code, research_response.text)
evidence_response = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
return article_id
def _create_article_with_approved_plan(self) -> str:
article_response = self.client.post(
"/api/articles",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"target_site_id": self._first_site_id(),
"brief_description": "Observe failed job history and retry eligibility.",
"working_title": "Observability Retry Audit",
"content_type": "longform_guide",
"primary_keyword": "observability retry audit",
},
)
self.assertEqual(201, article_response.status_code, article_response.text)
article_id = article_response.json()["article"]["id"]
questions = self.client.post(
f"/api/articles/{article_id}/boundary-questions/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["questions"]
for question in questions:
if question["is_required"]:
patch_response = self.client.patch(
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"answer": f"Answer for {question['category']}"},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
submit_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/submit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, submit_response.status_code, submit_response.text)
plan_response = self.client.post(
f"/api/articles/{article_id}/plan/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, plan_response.status_code, plan_response.text)
plan = plan_response.json()["plan"]
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,321 @@
from __future__ import annotations
import os
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_ADMIN_EMAIL = "admin@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class ParallelProductionPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects")
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'parallel-production.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_start_production_requires_evidence_matrix_ready(self) -> None:
article_id, _ = self._create_article_with_approved_plan()
research_response = self.client.post(
f"/api/articles/{article_id}/research/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, research_response.status_code, research_response.text)
start_response = self.client.post(
f"/api/articles/{article_id}/draft/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(409, start_response.status_code, start_response.text)
self.assertIn("Evidence matrix must be ready", start_response.text)
def test_start_production_creates_one_section_job_per_approved_plan_section(self) -> None:
article_id, approved_section_count = self._prepare_article_for_parallel_production()
start_production_response = self.client.post(
f"/api/articles/{article_id}/draft/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(202, start_production_response.status_code, start_production_response.text)
jobs = start_production_response.json()["jobs"]
section_jobs = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"]
self.assertEqual(
approved_section_count,
len(section_jobs),
"Expected one SECTION_SCAFFOLD job per approved plan section",
)
def test_section_jobs_run_independently_and_can_fail_independently(self) -> None:
article_id, _ = self._prepare_article_for_parallel_production()
created_jobs = self._start_parallel_production(article_id)
section_jobs = [job for job in created_jobs if job["job_type"] == "SECTION_SCAFFOLD"]
self.assertGreaterEqual(len(section_jobs), 2)
success_job = section_jobs[0]
failed_job = section_jobs[1]
complete_success = self._complete_job(
success_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/section-success.md"}],
"payload": {
"used_evidence_ids": success_job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": "Supported section draft",
},
},
)
self.assertEqual("SUCCEEDED", complete_success["status"])
self.assertIsNone(complete_success["error_category"])
unsupported_claims = [
{
"claim_text": "Unverified benchmark introduced during scaffolding.",
"risk_level": "high",
}
]
complete_failure = self._complete_job(
failed_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/section-failed.md"}],
"payload": {
"used_evidence_ids": failed_job["payload"]["used_evidence_ids"],
"unsupported_claims": unsupported_claims,
"draft_markdown": "Draft includes unsupported claim",
},
},
)
self.assertEqual("FAILED", complete_failure["status"])
self.assertEqual("UNSUPPORTED_CLAIMS_FOUND", complete_failure["error_category"])
self.assertEqual(unsupported_claims, complete_failure["payload"]["unsupported_claims"])
def test_retry_failed_section_does_not_rerun_successful_sections(self) -> None:
article_id, _ = self._prepare_article_for_parallel_production()
created_jobs = self._start_parallel_production(article_id)
section_jobs = [job for job in created_jobs if job["job_type"] == "SECTION_SCAFFOLD"]
self.assertGreaterEqual(len(section_jobs), 2)
success_job = self._complete_job(
section_jobs[0]["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/section-success.md"}],
"payload": {"unsupported_claims": []},
},
)
failed_job = self._complete_job(
section_jobs[1]["id"],
output={
"status": "FAILED",
"error_category": "CLI_EXIT_CODE_FAILURE",
"error_message": "Runner failed for one section",
"payload": {"unsupported_claims": []},
},
)
retry_response = self.client.post(
f"/api/agent-jobs/{failed_job['id']}/retry",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(201, retry_response.status_code, retry_response.text)
retry_job = retry_response.json()["job"]
self.assertEqual("QUEUED", retry_job["status"])
self.assertEqual(failed_job["id"], retry_job["parent_job_id"])
self.assertEqual(2, retry_job["attempt"])
detail = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()
successful_descendants = [
job
for job in detail["agent_jobs"]
if job.get("parent_job_id") == success_job["id"]
]
failed_descendants = [
job
for job in detail["agent_jobs"]
if job.get("parent_job_id") == failed_job["id"]
]
self.assertEqual([], successful_descendants)
self.assertEqual(1, len(failed_descendants))
self.assertEqual("QUEUED", failed_descendants[0]["status"])
def test_each_scaffold_payload_lists_used_evidence_ids(self) -> None:
article_id, _ = self._prepare_article_for_parallel_production()
evidence = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["evidence"]
evidence_ids = {item["id"] for item in evidence}
jobs = self._start_parallel_production(article_id)
section_jobs = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"]
self.assertTrue(section_jobs)
self.assertTrue(any(job["payload"]["used_evidence_ids"] for job in section_jobs))
for job in section_jobs:
self.assertIn("used_evidence_ids", job["payload"])
self.assertIsInstance(job["payload"]["used_evidence_ids"], list)
for evidence_id in job["payload"]["used_evidence_ids"]:
self.assertIn(evidence_id, evidence_ids)
def test_unsupported_claims_from_scaffold_are_captured_in_article_detail(self) -> None:
article_id, _ = self._prepare_article_for_parallel_production()
jobs = self._start_parallel_production(article_id)
section_job = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"][0]
unsupported_claims = [
{
"claim_text": "Unverified migration timeline claim",
"risk_level": "medium",
}
]
completed_job = self._complete_job(
section_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/section-with-unsupported.md"}],
"payload": {
"used_evidence_ids": section_job["payload"]["used_evidence_ids"],
"unsupported_claims": unsupported_claims,
"draft_markdown": "Draft with unsupported claim",
},
},
)
self.assertEqual("FAILED", completed_job["status"])
self.assertEqual("UNSUPPORTED_CLAIMS_FOUND", completed_job["error_category"])
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)
article_jobs = detail_response.json()["agent_jobs"]
refreshed_job = [job for job in article_jobs if job["id"] == section_job["id"]][0]
self.assertEqual(unsupported_claims, refreshed_job["payload"]["unsupported_claims"])
self.assertIn("Unsupported claims introduced during scaffolding", refreshed_job["error_message"])
def _start_parallel_production(self, article_id: str) -> list[dict[str, Any]]:
response = self.client.post(
f"/api/articles/{article_id}/draft/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(202, response.status_code, response.text)
return response.json()["jobs"]
def _complete_job(self, job_id: str, *, output: dict[str, Any]) -> dict[str, Any]:
response = self.client.post(
f"/internal/agent-jobs/{job_id}/complete",
json={
"workspace_path": f"/tmp/{job_id}",
"stdout": "fake section scaffolding runner\n",
"stderr": "",
"exit_code": 0,
"duration_ms": 2,
"output": output,
},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["job"]
def _prepare_article_for_parallel_production(self) -> tuple[str, int]:
article_id, approved_section_count = self._create_article_with_approved_plan()
research_response = self.client.post(
f"/api/articles/{article_id}/research/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, research_response.status_code, research_response.text)
evidence_response = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
self.assertFalse(evidence_response.json()["insufficient_evidence_reasons"])
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
return article_id, approved_section_count
def _create_article_with_approved_plan(self) -> tuple[str, int]:
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": "Run parallel production jobs from approved sections.",
"working_title": "Parallel Production Jobs",
"content_type": "longform_guide",
"primary_keyword": "parallel production jobs",
},
).json()["article"]
article_id = article["id"]
questions = self.client.post(
f"/api/articles/{article_id}/boundary-questions/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["questions"]
for question in questions:
if question["is_required"]:
patch_response = self.client.patch(
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"answer": f"Answer for {question['category']}"},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
submit_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/submit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, submit_response.status_code, submit_response.text)
plan_response = self.client.post(
f"/api/articles/{article_id}/plan/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, plan_response.status_code, plan_response.text)
plan = plan_response.json()["plan"]
self.assertGreaterEqual(len(plan["sections"]), 1)
approve_response = self.client.post(
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, approve_response.status_code, approve_response.text)
return article_id, len(plan["sections"])
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,464 @@
from __future__ import annotations
import base64
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi.testclient import TestClient
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
from src.application.seed_data import seed_reference_data # noqa: E402
from src.infrastructure.repositories import open_backend_repository # noqa: E402
from src.presentation.dependencies import get_repository # noqa: E402
from src.presentation.main import app # noqa: E402
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_ADMIN_EMAIL = "admin@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class PublishingGitFlowPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
self.objects_root = Path(self.tmp_dir.name) / "objects"
self.bare_repo_path = Path(self.tmp_dir.name) / "demo-site.git"
self.seed_repo_path = Path(self.tmp_dir.name) / "demo-site-seed"
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(self.objects_root)
self._prepare_local_bare_git_repo()
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'publishing-git-flow.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_publishing_dry_run_blocked_before_final_approval(self) -> None:
article_id, _ = self._prepare_article_ready_for_final_approval()
response = self.client.post(
f"/api/articles/{article_id}/publishing/dry-run",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(409, response.status_code, response.text)
self.assertIn("final approval", response.text.lower())
def test_publishing_dry_run_validation_failure_sets_failed_status(self) -> None:
article_id, draft = self._prepare_final_approved_article()
patch_response = self.client.patch(
f"/api/articles/{article_id}/drafts/{draft['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"body_markdown": "Only plain text, no markdown heading present."},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
response = self.client.post(
f"/api/articles/{article_id}/publishing/dry-run",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, response.status_code, response.text)
body = response.json()
self.assertFalse(body["content_shape_valid"])
self.assertIn("best-effort", body["validation_label"].lower())
self.assertIn("heading", " ".join(body["errors"]).lower())
self.assertEqual("PUBLISH_DRY_RUN_FAILED", body["article"]["publishing_status"])
self.assertEqual("PUBLISH_DRY_RUN_REQUIRED", body["article"]["status"])
def test_publish_commit_blocked_before_successful_dry_run(self) -> None:
article_id, _ = self._prepare_final_approved_article()
response = self.client.post(
f"/api/articles/{article_id}/publishing/create-commit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(409, response.status_code, response.text)
self.assertIn("dry run", response.text.lower())
def test_final_approved_article_can_create_commit_in_local_git_repository(self) -> None:
article_id, _ = self._prepare_final_approved_article()
dry_run_response = self.client.post(
f"/api/articles/{article_id}/publishing/dry-run",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, dry_run_response.status_code, dry_run_response.text)
self.assertTrue(dry_run_response.json()["content_shape_valid"])
self.assertEqual("PUBLISH_COMMIT_READY", dry_run_response.json()["article"]["status"])
commit_response = self.client.post(
f"/api/articles/{article_id}/publishing/create-commit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, commit_response.status_code, commit_response.text)
body = commit_response.json()
commit = body["publish_commit"]
commit_sha = commit["commit_sha"]
self.assertTrue(commit_sha)
self.assertEqual(str(self.bare_repo_path), commit["repository_url"])
self.assertEqual("main", commit["branch"])
self.assertEqual("PUBLISH_COMMIT_CREATED", commit["status"])
check = subprocess.run(
["git", "--git-dir", str(self.bare_repo_path), "cat-file", "-e", f"{commit_sha}^{{commit}}"],
capture_output=True,
text=True,
)
self.assertEqual(0, check.returncode, check.stderr)
def test_non_fast_forward_conflict_fails_without_rebase(self) -> None:
article_id, _ = self._prepare_final_approved_article()
dry_run_response = self.client.post(
f"/api/articles/{article_id}/publishing/dry-run",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, dry_run_response.status_code, dry_run_response.text)
self._push_remote_commit("competing remote update")
commit_response = self.client.post(
f"/api/articles/{article_id}/publishing/create-commit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(409, commit_response.status_code, commit_response.text)
self.assertIn("non-fast-forward", commit_response.text.lower())
def test_publishing_dry_run_supports_s3_asset_urls_with_object_keys(self) -> None:
article_id, _ = self._prepare_final_approved_article()
self._rewrite_asset_urls_to_s3(article_id)
dry_run_response = self.client.post(
f"/api/articles/{article_id}/publishing/dry-run",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, dry_run_response.status_code, dry_run_response.text)
body = dry_run_response.json()
self.assertTrue(body["content_shape_valid"])
self.assertEqual("PUBLISH_COMMIT_READY", body["article"]["status"])
self.assertEqual("PUBLISH_COMMIT_READY", body["article"]["publishing_status"])
def test_status_and_commits_endpoints_expose_required_metadata(self) -> None:
article_id, _ = self._prepare_final_approved_article()
dry_run_response = self.client.post(
f"/api/articles/{article_id}/publishing/dry-run",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, dry_run_response.status_code, dry_run_response.text)
commit_response = self.client.post(
f"/api/articles/{article_id}/publishing/create-commit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, commit_response.status_code, commit_response.text)
status_response = self.client.get(
f"/api/articles/{article_id}/publishing/status",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, status_response.status_code, status_response.text)
status_body = status_response.json()
self.assertIn("best-effort", status_body["validation_label"].lower())
self.assertEqual("PUBLISH_COMMIT_CREATED", status_body["article"]["status"])
self.assertEqual("PUBLISH_COMMIT_CREATED", status_body["article"]["publishing_status"])
self.assertIsNotNone(status_body["latest_dry_run"])
self.assertIsNotNone(status_body["latest_publish_commit"])
commits_response = self.client.get(
f"/api/articles/{article_id}/publishing/commits",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, commits_response.status_code, commits_response.text)
commits = commits_response.json()["commits"]
self.assertGreaterEqual(len(commits), 2)
commit_rows = {row["status"]: row for row in commits}
self.assertIn("PUBLISH_COMMIT_READY", commit_rows)
self.assertIn("PUBLISH_COMMIT_CREATED", commit_rows)
created = commit_rows["PUBLISH_COMMIT_CREATED"]
self.assertEqual(str(self.bare_repo_path), created["repository_url"])
self.assertEqual("main", created["branch"])
self.assertTrue(created["commit_sha"])
manifest = created["content_bundle_manifest"]
self.assertIn("content", manifest)
self.assertIn("frontmatter", manifest)
self.assertIn("assets", manifest)
self.assertIn("config_version", manifest)
self.assertIn("git", manifest)
self.assertEqual("main", manifest["git"]["branch"])
self.assertEqual(str(self.bare_repo_path), manifest["git"]["repository_url"])
self.assertIn("best-effort", manifest["validation"]["label"].lower())
self.assertEqual("content/articles/git-publishing-flow.mdx", manifest["content"]["path"])
def _prepare_local_bare_git_repo(self) -> None:
self._run_git(["init", "--bare", str(self.bare_repo_path)])
self.seed_repo_path.mkdir(parents=True, exist_ok=True)
self._run_git(["init"], cwd=self.seed_repo_path)
self._run_git(["config", "user.name", "Pipeline Bot"], cwd=self.seed_repo_path)
self._run_git(["config", "user.email", "pipeline-bot@example.com"], cwd=self.seed_repo_path)
(self.seed_repo_path / "README.md").write_text("# Demo Site\n", encoding="utf-8")
self._run_git(["add", "README.md"], cwd=self.seed_repo_path)
self._run_git(["commit", "-m", "seed"], cwd=self.seed_repo_path)
self._run_git(["branch", "-M", "main"], cwd=self.seed_repo_path)
self._run_git(["remote", "add", "origin", str(self.bare_repo_path)], cwd=self.seed_repo_path)
self._run_git(["push", "origin", "main"], cwd=self.seed_repo_path)
def _rewrite_asset_urls_to_s3(self, article_id: str) -> None:
assets = self.repository.assets.list_for_article(UUID(article_id))
now = self.repository.articles.get(UUID(article_id)).updated_at
for asset in assets:
if asset.status.value != "APPROVED":
continue
self.assertTrue(asset.object_key)
self.repository.assets.update(
article_id=UUID(article_id),
asset_id=asset.id,
section_id=asset.section_id,
title=asset.title,
prompt=asset.prompt,
object_key=asset.object_key,
file_url=f"s3://pipeline-local/{asset.object_key}",
alt_text=asset.alt_text,
caption=asset.caption,
status=asset.status,
updated_at=now,
)
def _run_git(self, args: list[str], *, cwd: Path | None = None) -> None:
subprocess.run(
["git", *args],
cwd=str(cwd) if cwd is not None else None,
check=True,
capture_output=True,
text=True,
)
def _push_remote_commit(self, message: str) -> None:
readme_path = self.seed_repo_path / "README.md"
readme_path.write_text(
readme_path.read_text(encoding="utf-8") + f"\n{message}\n",
encoding="utf-8",
)
self._run_git(["add", "README.md"], cwd=self.seed_repo_path)
self._run_git(["commit", "-m", message], cwd=self.seed_repo_path)
self._run_git(["push", "origin", "main"], cwd=self.seed_repo_path)
def _prepare_final_approved_article(self) -> tuple[str, dict[str, Any]]:
article_id, draft = self._prepare_article_ready_for_final_approval()
self._submit_final_approval(article_id, draft=draft)
return article_id, draft
def _prepare_article_ready_for_final_approval(self) -> tuple[str, dict[str, Any]]:
site_id = self._configure_local_git_repository_for_site()
article_id = self._create_article_with_approved_plan(site_id)
self._ensure_evidence_ready(article_id)
self._approve_all_evidence(article_id)
section_jobs = self._start_parallel_production(article_id)
self.assertGreaterEqual(len(section_jobs), 1)
for index, job in enumerate(section_jobs, start=1):
completed = self._complete_job(
job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": f"outputs/section-{index}.md"}],
"payload": {
"used_evidence_ids": job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": (
f"## {job['payload']['heading']}\n\n"
"Publishing-ready section with [internal link](/guides/internal)."
),
},
},
)
self.assertEqual("SUCCEEDED", completed["status"])
assemble_response = self.client.post(
f"/api/articles/{article_id}/draft/assemble",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, assemble_response.status_code, assemble_response.text)
draft = assemble_response.json()["draft"]
assets_response = self.client.post(
f"/api/articles/{article_id}/assets/generate-specs",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, assets_response.status_code, assets_response.text)
assets = assets_response.json()["assets"]
self.assertTrue(assets)
for asset in assets:
upload_response = self.client.post(
f"/api/articles/{article_id}/assets/{asset['id']}/upload",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"filename": f"{asset['id']}.png",
"content_base64": base64.b64encode(b"asset-binary").decode("utf-8"),
"content_type": "image/png",
},
)
self.assertEqual(200, upload_response.status_code, upload_response.text)
approve_response = self.client.post(
f"/api/articles/{article_id}/assets/{asset['id']}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, approve_response.status_code, approve_response.text)
return article_id, draft
def _submit_final_approval(self, article_id: str, *, draft: dict[str, Any]) -> None:
approval_response = self.client.post(
f"/api/articles/{article_id}/final-approval",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"draft_version": draft["version"],
"publishing_settings": {
"content_path": "/guides/git-publishing-flow",
"author": "Editorial Team",
"publishing_mode": "MANUAL",
"frontmatter": {"title": draft["title"], "category": "Guides"},
},
},
)
self.assertEqual(200, approval_response.status_code, approval_response.text)
self.assertEqual("PUBLISH_DRY_RUN_REQUIRED", approval_response.json()["article"]["status"])
def _configure_local_git_repository_for_site(self) -> UUID:
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)
site = sites_response.json()[0]["site"]
publishing_rules = dict(site["publishing_rules"])
publishing_rules["repository_url"] = str(self.bare_repo_path)
publishing_rules["production_branch"] = "main"
patch_response = self.client.patch(
f"/api/sites/{site['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json={"publishing_rules": publishing_rules},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
return UUID(site["id"])
def _create_article_with_approved_plan(self, site_id: UUID) -> str:
article_response = self.client.post(
"/api/articles",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"target_site_id": str(site_id),
"brief_description": "Publish final-approved content into local git repository.",
"working_title": "Git Publishing Dry Run And Commit",
"content_type": "longform_guide",
"primary_keyword": "git publishing dry run",
},
)
self.assertEqual(201, article_response.status_code, article_response.text)
article_id = article_response.json()["article"]["id"]
questions_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, questions_response.status_code, questions_response.text)
questions = questions_response.json()["questions"]
for question in questions:
if question["is_required"]:
patch_response = self.client.patch(
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"answer": f"Answer for {question['category']}"},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
submit_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/submit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, submit_response.status_code, submit_response.text)
plan_response = self.client.post(
f"/api/articles/{article_id}/plan/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, plan_response.status_code, plan_response.text)
plan = plan_response.json()["plan"]
self.assertGreaterEqual(len(plan["sections"]), 1)
approve_response = self.client.post(
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, approve_response.status_code, approve_response.text)
return article_id
def _ensure_evidence_ready(self, article_id: str) -> None:
research_response = self.client.post(
f"/api/articles/{article_id}/research/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, research_response.status_code, research_response.text)
evidence_response = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
def _approve_all_evidence(self, article_id: str) -> None:
response = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, response.status_code, response.text)
for item in response.json()["evidence"]:
patch = self.client.patch(
f"/api/articles/{article_id}/evidence/{item['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"review_status": "APPROVED"},
)
self.assertEqual(200, patch.status_code, patch.text)
def _start_parallel_production(self, article_id: str) -> list[dict[str, Any]]:
response = self.client.post(
f"/api/articles/{article_id}/draft/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(202, response.status_code, response.text)
return [job for job in response.json()["jobs"] if job["job_type"] == "SECTION_SCAFFOLD"]
def _complete_job(self, job_id: str, *, output: dict[str, Any]) -> dict[str, Any]:
response = self.client.post(
f"/internal/agent-jobs/{job_id}/complete",
json={
"workspace_path": f"/tmp/{job_id}",
"stdout": "fake section scaffolding runner\n",
"stderr": "",
"exit_code": 0,
"duration_ms": 1,
"output": output,
},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["job"]
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,174 @@
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.assertIn(
"review_status",
repository.schema.list_columns("evidence_items"),
)
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,136 @@
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:
from src.domain.contracts import 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()
@@ -0,0 +1,431 @@
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi.testclient import TestClient
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
from src.application.seed_data import seed_reference_data # noqa: E402
from src.infrastructure.repositories import open_backend_repository # noqa: E402
from src.presentation.dependencies import get_repository # noqa: E402
from src.presentation.main import app # noqa: E402
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_ADMIN_EMAIL = "admin@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class SeoLanguageReviewPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects")
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'seo-language-review.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_seo_report_includes_required_fields(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
self._run_seo_review(article_id)
report = self._get_seo_report(article_id)
self.assertIn("score", report)
self.assertIn("issues", report)
self.assertIn("recommended_slug", report)
self.assertIn("recommended_title", report)
self.assertIn("schema_json", report)
self.assertGreaterEqual(len(report["issues"]), 1)
first_issue = report["issues"][0]
self.assertIn("severity", first_issue)
self.assertIn("location", first_issue)
self.assertIn("message", first_issue)
self.assertIn("suggested_fix", first_issue)
self.assertIn("suggestion_id", first_issue)
def test_language_report_includes_required_fields(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
self._seed_language_problematic_draft(article_id)
self._run_language_review(article_id)
report = self._get_language_report(article_id)
self.assertGreaterEqual(len(report["issues"]), 1)
self.assertTrue(any(issue.get("suggested_rewrite") for issue in report["issues"]))
first_issue = report["issues"][0]
self.assertIn("severity", first_issue)
self.assertIn("location", first_issue)
self.assertIn("message", first_issue)
self.assertIn("suggested_rewrite", first_issue)
def test_editor_actions_accept_reject_edit_and_immutable_drafts(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
self._seed_language_problematic_draft(article_id)
self._run_seo_review(article_id)
self._run_language_review(article_id)
seo_report = self._get_seo_report(article_id)
language_report = self._get_language_report(article_id)
base_latest = self._latest_draft(article_id)
seo_patch_issue = next(
issue for issue in seo_report["issues"] if issue["location"] == "meta_description"
)
accept_response = self.client.post(
f"/api/articles/{article_id}/seo/suggestions/{seo_patch_issue['suggestion_id']}/accept",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, accept_response.status_code, accept_response.text)
accepted = accept_response.json()
self.assertEqual("ACCEPTED", accepted["suggestion"]["status"])
self.assertEqual(base_latest["version"] + 1, accepted["draft"]["version"])
seo_non_patch_issue = next(
issue for issue in seo_report["issues"] if issue["location"] == "body_markdown"
)
reject_response = self.client.post(
f"/api/articles/{article_id}/seo/suggestions/{seo_non_patch_issue['suggestion_id']}/reject",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, reject_response.status_code, reject_response.text)
self.assertEqual("REJECTED", reject_response.json()["suggestion"]["status"])
self.assertIsNone(reject_response.json()["draft"])
language_rewrite_issue = next(
issue for issue in language_report["issues"] if "Sentence exceeds" in issue["message"]
)
edit_response = self.client.post(
f"/api/articles/{article_id}/language/suggestions/{language_rewrite_issue['suggestion_id']}/edit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"applied_text": "This sentence now states the same idea clearly."},
)
self.assertEqual(200, edit_response.status_code, edit_response.text)
edited = edit_response.json()
self.assertEqual("EDITED", edited["suggestion"]["status"])
self.assertEqual(
accepted["draft"]["version"] + 1,
edited["draft"]["version"],
)
self.assertIn("states the same idea clearly", edited["draft"]["body_markdown"])
def test_target_site_overrides_are_applied(self) -> None:
site = self.client.get(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()[0]["site"]
patch_response = self.client.patch(
f"/api/sites/{site['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json={
"seo_rules": {
"seo_review": {
"meta_description_min": 10,
"meta_description_max": 40,
"schema_type": "HowTo",
},
"language_review": {
"max_sentence_words": 8,
"forbidden_phrases": ["precision tone"],
"brand_tone": "strict",
},
}
},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
article_id = self._prepare_article_with_assembled_draft(target_site_id=UUID(site["id"]))
self._seed_language_problematic_draft(
article_id,
extra_body="This precision tone phrase should be avoided!",
)
self._run_seo_review(article_id)
self._run_language_review(article_id)
seo_report = self._get_seo_report(article_id)
self.assertEqual("HowTo", seo_report["schema_json"]["@type"])
language_report = self._get_language_report(article_id)
self.assertEqual(8, language_report["rules_snapshot"]["max_sentence_words"])
self.assertEqual("strict", language_report["rules_snapshot"]["brand_tone"])
self.assertTrue(
any("8 words" in issue["message"] for issue in language_report["issues"])
)
self.assertTrue(
any("Forbidden phrase" in issue["message"] for issue in language_report["issues"])
)
def test_final_review_shows_unresolved_issues(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
self._seed_language_problematic_draft(article_id)
self._run_seo_review(article_id)
self._run_language_review(article_id)
seo_report = self._get_seo_report(article_id)
issue_to_accept = seo_report["issues"][0]
accept_response = self.client.post(
f"/api/articles/{article_id}/seo/suggestions/{issue_to_accept['suggestion_id']}/accept",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, accept_response.status_code, accept_response.text)
unresolved_response = self.client.get(
f"/api/articles/{article_id}/final-review/issues",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, unresolved_response.status_code, unresolved_response.text)
unresolved = unresolved_response.json()
self.assertGreaterEqual(unresolved["unresolved_count"], 1)
self.assertTrue(unresolved["unresolved_issues"])
self.assertTrue(
all(item["status"] == "PENDING" for item in unresolved["unresolved_issues"])
)
def test_fake_runner_is_deterministic(self) -> None:
article_id = self._prepare_article_with_assembled_draft()
self._seed_language_problematic_draft(article_id)
self._run_seo_review(article_id)
first = self._get_seo_report(article_id)
self._run_seo_review(article_id)
second = self._get_seo_report(article_id)
self.assertEqual(first["score"], second["score"])
self.assertEqual(first["recommended_slug"], second["recommended_slug"])
self.assertEqual(first["recommended_title"], second["recommended_title"])
first_issue_signature = [
(
issue["id"],
issue["severity"],
issue["location"],
issue["message"],
issue["suggested_fix"],
issue["suggested_rewrite"],
)
for issue in first["issues"]
]
second_issue_signature = [
(
issue["id"],
issue["severity"],
issue["location"],
issue["message"],
issue["suggested_fix"],
issue["suggested_rewrite"],
)
for issue in second["issues"]
]
self.assertEqual(first_issue_signature, second_issue_signature)
def _run_seo_review(self, article_id: UUID) -> dict[str, Any]:
response = self.client.post(
f"/api/articles/{article_id}/seo/review",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, response.status_code, response.text)
return response.json()["report"]
def _run_language_review(self, article_id: UUID) -> dict[str, Any]:
response = self.client.post(
f"/api/articles/{article_id}/language/review",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, response.status_code, response.text)
return response.json()["report"]
def _get_seo_report(self, article_id: UUID) -> dict[str, Any]:
response = self.client.get(
f"/api/articles/{article_id}/seo/report",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["report"]
def _get_language_report(self, article_id: UUID) -> dict[str, Any]:
response = self.client.get(
f"/api/articles/{article_id}/language/report",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["report"]
def _latest_draft(self, article_id: UUID) -> dict[str, Any]:
response = self.client.get(
f"/api/articles/{article_id}/drafts",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["drafts"][0]
def _seed_language_problematic_draft(
self,
article_id: UUID,
*,
extra_body: str = "",
) -> None:
latest = self._latest_draft(article_id)
body = "\n".join(
[
latest["body_markdown"],
(
"This sentence is intentionally extremely long and includes many additional words "
"that make it exceed any practical editorial threshold for readability while still "
"describing the same guidance in an unnecessarily verbose and repetitive way for testing."
),
"Our approach is the best ever for everyone.",
extra_body,
]
)
patch_response = self.client.patch(
f"/api/articles/{article_id}/drafts/{latest['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"body_markdown": body},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
def _prepare_article_with_assembled_draft(
self,
*,
target_site_id: UUID | None = None,
) -> UUID:
article_id, _ = self._prepare_article_with_approved_plan(target_site_id=target_site_id)
self._ensure_evidence_ready(article_id)
section_jobs = self._start_parallel_production(article_id)
self._complete_all_section_jobs_success(section_jobs)
self._assemble_draft(article_id)
return article_id
def _prepare_article_with_approved_plan(
self,
*,
target_site_id: UUID | None = None,
) -> tuple[UUID, dict[str, Any]]:
if target_site_id is None:
site = self.client.get(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()[0]["site"]
target_site_id = UUID(site["id"])
article = self.client.post(
"/api/articles",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"target_site_id": str(target_site_id),
"brief_description": "SEO and language review implementation coverage.",
"working_title": "Short",
"content_type": "longform_guide",
"primary_keyword": "seo language review",
},
).json()["article"]
article_id = UUID(article["id"])
questions = self.client.post(
f"/api/articles/{article_id}/boundary-questions/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["questions"]
for question in questions:
if question["is_required"]:
patch_response = self.client.patch(
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"answer": f"Answer for {question['category']}"},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
submit_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/submit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, submit_response.status_code, submit_response.text)
plan_response = self.client.post(
f"/api/articles/{article_id}/plan/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, plan_response.status_code, plan_response.text)
plan = plan_response.json()["plan"]
approve_plan_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_plan_response.status_code, approve_plan_response.text)
return article_id, plan
def _ensure_evidence_ready(self, article_id: UUID) -> None:
research_response = self.client.post(
f"/api/articles/{article_id}/research/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, research_response.status_code, research_response.text)
evidence_response = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
def _start_parallel_production(self, article_id: UUID) -> list[dict[str, Any]]:
response = self.client.post(
f"/api/articles/{article_id}/draft/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(202, response.status_code, response.text)
jobs = response.json()["jobs"]
section_jobs = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"]
self.assertTrue(section_jobs)
return section_jobs
def _complete_all_section_jobs_success(self, section_jobs: list[dict[str, Any]]) -> None:
for index, job in enumerate(section_jobs, start=1):
complete_response = self.client.post(
f"/internal/agent-jobs/{job['id']}/complete",
json={
"workspace_path": f"/tmp/{job['id']}",
"stdout": "fake section scaffolding runner\n",
"stderr": "",
"exit_code": 0,
"duration_ms": 1,
"output": {
"status": "SUCCEEDED",
"output_files": [{"path": f"outputs/section-{index}.md"}],
"payload": {
"used_evidence_ids": job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": f"## {job['payload']['heading']}\n\nSection {index}.",
},
},
},
)
self.assertEqual(200, complete_response.status_code, complete_response.text)
self.assertEqual("SUCCEEDED", complete_response.json()["job"]["status"])
def _assemble_draft(self, article_id: UUID) -> dict[str, Any]:
response = self.client.post(
f"/api/articles/{article_id}/draft/assemble",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, response.status_code, response.text)
return response.json()["draft"]
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,288 @@
from __future__ import annotations
import sys
import tempfile
import unittest
from pathlib import Path
from typing import Any
from uuid import uuid4
from fastapi.testclient import TestClient
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
from src.application.seed_data import seed_reference_data # noqa: E402
from src.infrastructure.repositories import open_backend_repository # noqa: E402
from src.presentation.dependencies import get_repository # noqa: E402
from src.presentation.main import app # noqa: E402
DEMO_ADMIN_EMAIL = "admin@example.com"
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class WorkflowTemplateBindingPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'workflow-template-binding.db'}"
self.repository = open_backend_repository(dsn)
self.repository.setup()
seed_reference_data(self.repository)
app.dependency_overrides[get_repository] = lambda: self.repository
self.client = TestClient(app)
def tearDown(self) -> None:
app.dependency_overrides.clear()
self.tmp_dir.cleanup()
def test_article_creation_stores_active_workflow_template_snapshot(self) -> None:
workflow = self._create_active_workflow("article-binding-active")
target_site = self._first_target_site()
create_response = self.client.post(
"/api/articles",
headers=self._editor_headers(),
json={
"target_site_id": target_site["id"],
"workflow_template_id": workflow["id"],
"brief_description": (
"Create an article that is governed by an Admin-managed "
f"workflow template {uuid4()}."
),
"working_title": "Workflow-bound article",
"content_type": "longform_guide",
"primary_keyword": "workflow template binding",
},
)
self.assertEqual(201, create_response.status_code, create_response.text)
article = create_response.json()["article"]
self.assertEqual(workflow["id"], article["workflow_template_id"])
self.assertEqual(workflow["version"], article["workflow_template_version"])
archive_response = self.client.post(
f"/api/admin/workflows/{workflow['id']}/archive",
headers=self._admin_headers(),
)
self.assertEqual(200, archive_response.status_code, archive_response.text)
detail_response = self.client.get(
f"/api/articles/{article['id']}",
headers=self._editor_headers(),
)
self.assertEqual(200, detail_response.status_code, detail_response.text)
detail = detail_response.json()
expected_stage_summary = [
{
"id": stage["id"],
"stable_key": stage["stable_key"],
"display_name": stage["display_name"],
"position": stage["position"],
"owner_role": stage["owner_role"],
"runner_profile_key": stage["runner_profile_key"],
"requires_human_approval": stage["requires_human_approval"],
}
for stage in workflow["stages"]
]
self.assertEqual(
{
"id": workflow["id"],
"name": workflow["name"],
"slug": workflow["slug"],
"version": workflow["version"],
"stage_summary": expected_stage_summary,
},
detail["workflow_template_snapshot"],
)
def test_article_creation_rejects_archived_workflow_template_reference(self) -> None:
workflow = self._create_active_workflow("article-binding-archived")
archive_response = self.client.post(
f"/api/admin/workflows/{workflow['id']}/archive",
headers=self._admin_headers(),
)
self.assertEqual(200, archive_response.status_code, archive_response.text)
response = self._create_article_with_workflow_template(workflow["id"])
self.assertEqual(400, response.status_code, response.text)
self.assertEqual("Workflow template must be active", response.json()["detail"])
def test_article_creation_rejects_missing_workflow_template_reference(self) -> None:
response = self._create_article_with_workflow_template(str(uuid4()))
self.assertEqual(400, response.status_code, response.text)
self.assertEqual("Invalid workflow_template_id", response.json()["detail"])
def test_seeded_active_workflow_template_is_available_for_editor_articles(self) -> None:
workflows_response = self.client.get(
"/api/admin/workflows",
headers=self._editor_headers(),
)
self.assertEqual(200, workflows_response.status_code, workflows_response.text)
seeded_workflow = next(
workflow
for workflow in workflows_response.json()["workflows"]
if workflow["slug"] == "demo-article-production"
)
self.assertEqual("ACTIVE", seeded_workflow["status"])
self.assertGreaterEqual(len(seeded_workflow["stages"]), 7)
create_response = self._create_article_with_workflow_template(
seeded_workflow["id"]
)
self.assertEqual(201, create_response.status_code, create_response.text)
article = create_response.json()["article"]
self.assertEqual(seeded_workflow["id"], article["workflow_template_id"])
self.assertEqual(
seeded_workflow["version"],
article["workflow_template_version"],
)
def _create_article_with_workflow_template(self, workflow_template_id: str) -> Any:
target_site = self._first_target_site()
return self.client.post(
"/api/articles",
headers=self._editor_headers(),
json={
"target_site_id": target_site["id"],
"workflow_template_id": workflow_template_id,
"brief_description": (
"Create an article using the selected workflow template "
f"{uuid4()}."
),
"content_type": "longform_guide",
"primary_keyword": "workflow binding guardrail",
},
)
def _create_active_workflow(self, slug_prefix: str) -> dict[str, Any]:
create_response = self.client.post(
"/api/admin/workflows",
headers=self._admin_headers(),
json={
"name": "Article production workflow",
"slug": f"{slug_prefix}-{uuid4()}",
"description": "Reusable editorial workflow for article production.",
},
)
self.assertEqual(201, create_response.status_code, create_response.text)
workflow = create_response.json()["workflow"]
workflow_id = workflow["id"]
for stage_payload in (
self._intake_stage_payload(),
self._draft_stage_payload(),
):
stage_response = self.client.post(
f"/api/admin/workflows/{workflow_id}/stages",
headers=self._admin_headers(),
json=stage_payload,
)
self.assertEqual(201, stage_response.status_code, stage_response.text)
activate_response = self.client.post(
f"/api/admin/workflows/{workflow_id}/activate",
headers=self._admin_headers(),
)
self.assertEqual(200, activate_response.status_code, activate_response.text)
workflow = activate_response.json()["workflow"]
self.assertEqual("ACTIVE", workflow["status"])
self.assertEqual(2, workflow["version"])
return workflow
def _first_target_site(self) -> dict[str, Any]:
response = self.client.get("/api/sites", headers=self._editor_headers())
self.assertEqual(200, response.status_code, response.text)
return response.json()[0]["site"]
def _intake_stage_payload(self) -> dict[str, object]:
return {
"stable_key": "intake-boundary-questions",
"display_name": "Boundary question intake",
"description": "Collect required editorial context before planning.",
"position": 1,
"owner_role": "EDITOR",
"runner_profile_key": "boundary-question-agent-v1",
"required_inputs": [
"article_brief",
"target_site",
],
"expected_outputs": [
"answered_boundary_questions",
],
"acceptance_criteria": [
"All required boundary questions are answered.",
],
"requires_human_approval": True,
"retry_policy": {
"max_attempts": 1,
"backoff_seconds": 0,
},
"parts": [
{
"key": "required-context-checklist",
"type": "checklist",
"title": "Required context checklist",
"payload": {
"prompt": "Identify missing audience and source constraints.",
},
"acceptance_criteria": [
"Missing required context is listed explicitly.",
],
}
],
}
def _draft_stage_payload(self) -> dict[str, object]:
return {
"stable_key": "draft-assembly",
"display_name": "Draft assembly",
"description": "Generate a first draft from the approved plan.",
"position": 2,
"owner_role": "EDITOR",
"runner_profile_key": "draft-writer-v1",
"required_inputs": [
"approved_plan",
],
"expected_outputs": [
"article_draft",
],
"acceptance_criteria": [
"Draft follows the approved plan.",
],
"requires_human_approval": False,
"retry_policy": {
"max_attempts": 2,
"backoff_seconds": 60,
},
"parts": [
{
"key": "draft-outline",
"type": "outline",
"title": "Draft outline",
"payload": {
"prompt": "Build a concise outline before drafting.",
},
"acceptance_criteria": [
"Every section has a purpose.",
],
}
],
}
def _admin_headers(self) -> dict[str, str]:
return {DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}
def _editor_headers(self) -> dict[str, str]:
return {DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}
if __name__ == "__main__":
unittest.main()
+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"]
+18
View File
@@ -1,6 +1,24 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
outputFileTracingRoot: repoRoot,
async rewrites() {
const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8000";
return [
{
source: "/api/:path*",
destination: `${backendUrl}/api/:path*`,
},
];
},
turbopack: {
root: repoRoot,
},
}; };
export default nextConfig; export default nextConfig;
+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/admin_workflows.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 && node tests/evidence_matrix.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,7 @@
import AdminJobsPage from "@/pages/admin-jobs";
export const dynamic = "force-dynamic";
export default function Page() {
return <AdminJobsPage />;
}
@@ -0,0 +1,7 @@
import AdminScriptsPage from "@/pages/admin-scripts";
export const dynamic = "force-dynamic";
export default function Page() {
return <AdminScriptsPage />;
}
@@ -0,0 +1,7 @@
import AdminSitesPage from "@/pages/admin-sites";
export const dynamic = "force-dynamic";
export default function Page() {
return <AdminSitesPage />;
}
@@ -0,0 +1,7 @@
import AdminWorkflowsPage from "@/pages/admin-workflows";
export const dynamic = "force-dynamic";
export default function Page() {
return <AdminWorkflowsPage />;
}
@@ -0,0 +1,16 @@
import ArticleBoundaryQuestionsPage from "@/pages/article-boundary-questions";
export const dynamic = "force-dynamic";
type DynamicArticleBoundaryQuestionsPageProps = {
params: Promise<{
articleId: string;
}>;
};
export default async function DynamicArticleBoundaryQuestionsPage({
params,
}: DynamicArticleBoundaryQuestionsPageProps) {
const { articleId } = await params;
return <ArticleBoundaryQuestionsPage articleId={articleId} />;
}
@@ -0,0 +1,16 @@
import ArticleEvidencePage from "@/pages/article-evidence";
export const dynamic = "force-dynamic";
type DynamicArticleEvidencePageProps = {
params: Promise<{
articleId: string;
}>;
};
export default async function DynamicArticleEvidencePage({
params,
}: DynamicArticleEvidencePageProps) {
const { articleId } = await params;
return <ArticleEvidencePage articleId={articleId} />;
}
@@ -0,0 +1,22 @@
import ArticleDetailPage from "@/pages/article-detail";
export const dynamic = "force-dynamic";
type DynamicArticleDetailPageProps = {
params: Promise<{
articleId: string;
}>;
searchParams?: Promise<{
role?: string;
}>;
};
export default async function DynamicArticleDetailPage({
params,
searchParams,
}: DynamicArticleDetailPageProps) {
const { articleId } = await params;
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const viewerRoleHint = resolvedSearchParams?.role === "admin" ? "admin" : "editor";
return <ArticleDetailPage articleId={articleId} viewerRoleHint={viewerRoleHint} />;
}
@@ -0,0 +1,16 @@
import ArticlePlansPage from "@/pages/article-plans";
export const dynamic = "force-dynamic";
type DynamicArticlePlansPageProps = {
params: Promise<{
articleId: string;
}>;
};
export default async function DynamicArticlePlansPage({
params,
}: DynamicArticlePlansPageProps) {
const { articleId } = await params;
return <ArticlePlansPage articleId={articleId} />;
}
@@ -0,0 +1,16 @@
import ArticleResearchPage from "@/pages/article-research";
export const dynamic = "force-dynamic";
type DynamicArticleResearchPageProps = {
params: Promise<{
articleId: string;
}>;
};
export default async function DynamicArticleResearchPage({
params,
}: DynamicArticleResearchPageProps) {
const { articleId } = await params;
return <ArticleResearchPage articleId={articleId} />;
}
@@ -0,0 +1,7 @@
import NewArticlePage from "@/pages/article-new";
export const dynamic = "force-dynamic";
export default function Page() {
return <NewArticlePage />;
}
+411
View File
@@ -19,3 +19,414 @@ 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;
}
.fieldHint {
margin: 0;
color: #475467;
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;
}
.timelineMeta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.timelineSource {
border: 1px solid #d0d5dd;
border-radius: 6px;
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 8px;
background: #ffffff;
font-size: 12px;
}
.draftEditor {
margin-top: 28px;
}
.reviewPanel {
margin-top: 28px;
}
.draftEditor .detailGrid select {
width: 100%;
border-radius: 6px;
border: 1px solid #d0d5dd;
padding: 10px;
font: inherit;
}
.previewPane {
border: 1px solid #d0d5dd;
border-radius: 8px;
background: #ffffff;
padding: 14px;
}
.previewImage {
margin: 0;
border: 1px dashed #d0d5dd;
border-radius: 8px;
padding: 10px;
}
.previewImage img {
display: block;
max-width: 100%;
max-height: 180px;
}
.previewImage figcaption {
margin-top: 6px;
color: #667085;
font-size: 12px;
}
.reviewActions {
display: grid;
grid-template-columns: repeat(2, minmax(88px, 1fr));
gap: 6px;
}
.reviewActions input {
grid-column: 1 / 3;
}
@media (max-width: 640px) {
main {
padding: 28px;
}
.pageHeader {
align-items: flex-start;
flex-direction: column;
}
}
+2
View File
@@ -6,6 +6,8 @@ export const metadata = {
description: "Internal editorial workflow foundation", description: "Internal editorial workflow foundation",
}; };
export const dynamic = "force-dynamic";
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
+6 -7
View File
@@ -1,8 +1,7 @@
export default function HomePage() { import DashboardPage from "@/pages/dashboard";
return (
<main> export const dynamic = "force-dynamic";
<h1>AI Content Pipeline</h1>
<p>Dockerized monorepo foundation is running.</p> export default function Page() {
</main> return <DashboardPage />;
);
} }

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