Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d4a79acb5 | ||
|
|
5b27d1a3ca | ||
|
|
b7e2fa34b2 | ||
|
|
8aa92ea728 | ||
|
|
4fd837349f | ||
|
|
ff1a2f78c0 | ||
|
|
cf84400118 | ||
|
|
7972e9a95f | ||
|
|
7a11d50aa7 | ||
|
|
a0ae06adfa |
@@ -2,6 +2,7 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
.next/
|
||||
*.tsbuildinfo
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
docker compose up --build
|
||||
@@ -6,5 +11,11 @@ dev:
|
||||
down:
|
||||
docker compose down --remove-orphans
|
||||
|
||||
migrate:
|
||||
alembic upgrade head
|
||||
|
||||
seed:
|
||||
PYTHONPATH=apps/backend $(PYTHON) -m src.infrastructure.seed_data
|
||||
|
||||
smoke:
|
||||
bash tests/smoke/public-health.sh
|
||||
|
||||
+41
@@ -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
|
||||
@@ -8,6 +8,8 @@ WORKDIR /app
|
||||
COPY apps/backend/requirements.txt ./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
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -1,5 +1,8 @@
|
||||
fastapi==0.115.6
|
||||
httpx==0.28.1
|
||||
uvicorn[standard]==0.34.0
|
||||
alembic==1.14.0
|
||||
SQLAlchemy==2.0.36
|
||||
psycopg[binary]==3.2.3
|
||||
redis==5.2.1
|
||||
boto3==1.35.90
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from src.domain.contracts import (
|
||||
AgentJobErrorCategory,
|
||||
AgentJobListResponse,
|
||||
AgentJobOutput,
|
||||
AgentJobResponse,
|
||||
AgentJobStatus,
|
||||
AgentJobTestCodexRequest,
|
||||
AgentJobType,
|
||||
)
|
||||
|
||||
|
||||
VALID_FAKE_AGENT_PROFILE = "fake-codex"
|
||||
INVALID_SCHEMA_FAKE_AGENT_PROFILE = "fake-invalid-schema"
|
||||
|
||||
|
||||
def create_test_codex_job(
|
||||
repository: object,
|
||||
request: AgentJobTestCodexRequest,
|
||||
) -> AgentJobResponse:
|
||||
agent_profile = (
|
||||
INVALID_SCHEMA_FAKE_AGENT_PROFILE
|
||||
if request.fake_result == "invalid_schema"
|
||||
else VALID_FAKE_AGENT_PROFILE
|
||||
)
|
||||
job = repository.agent_jobs.create(
|
||||
article_id=None,
|
||||
parent_job_id=None,
|
||||
attempt=1,
|
||||
job_type=AgentJobType.TEST_CODEX,
|
||||
agent_profile=agent_profile,
|
||||
status=AgentJobStatus.QUEUED,
|
||||
input_files=[{"path": "inputs/job.json"}],
|
||||
queued_at=_now(),
|
||||
)
|
||||
return AgentJobResponse(job=job)
|
||||
|
||||
|
||||
def list_agent_jobs(repository: object) -> AgentJobListResponse:
|
||||
return AgentJobListResponse(jobs=repository.agent_jobs.list())
|
||||
|
||||
|
||||
def get_agent_job(repository: object, job_id: UUID) -> AgentJobResponse:
|
||||
return AgentJobResponse(job=repository.agent_jobs.get(job_id))
|
||||
|
||||
|
||||
def retry_agent_job(repository: object, job_id: UUID) -> AgentJobResponse:
|
||||
parent = repository.agent_jobs.get(job_id)
|
||||
retry = repository.agent_jobs.create(
|
||||
article_id=parent.article_id,
|
||||
parent_job_id=parent.id,
|
||||
attempt=parent.attempt + 1,
|
||||
job_type=parent.job_type,
|
||||
agent_profile=parent.agent_profile,
|
||||
status=AgentJobStatus.QUEUED,
|
||||
input_files=[file_ref.model_dump(mode="json") for file_ref in parent.input_files],
|
||||
queued_at=_now(),
|
||||
)
|
||||
return AgentJobResponse(job=retry)
|
||||
|
||||
|
||||
def cancel_agent_job(repository: object, job_id: UUID) -> AgentJobResponse:
|
||||
return AgentJobResponse(job=repository.agent_jobs.cancel(job_id=job_id, finished_at=_now()))
|
||||
|
||||
|
||||
def claim_next_agent_job(repository: object) -> AgentJobResponse | None:
|
||||
job = repository.agent_jobs.claim_next_queued(started_at=_now())
|
||||
if job is None:
|
||||
return None
|
||||
return AgentJobResponse(job=job)
|
||||
|
||||
|
||||
def complete_agent_job(
|
||||
repository: object,
|
||||
*,
|
||||
job_id: UUID,
|
||||
workspace_path: str | None,
|
||||
stdout: str,
|
||||
stderr: str,
|
||||
exit_code: int | None,
|
||||
duration_ms: int | None,
|
||||
output: dict[str, Any],
|
||||
) -> AgentJobResponse:
|
||||
try:
|
||||
validated_output = AgentJobOutput.model_validate(output)
|
||||
except ValidationError as error:
|
||||
job = repository.agent_jobs.complete(
|
||||
job_id=job_id,
|
||||
status=AgentJobStatus.FAILED,
|
||||
workspace_path=workspace_path,
|
||||
output_files=[],
|
||||
error_category=AgentJobErrorCategory.FAILED_SCHEMA_VALIDATION,
|
||||
error_message=str(error),
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
exit_code=exit_code,
|
||||
duration_ms=duration_ms,
|
||||
finished_at=_now(),
|
||||
)
|
||||
return AgentJobResponse(job=job)
|
||||
|
||||
job = repository.agent_jobs.complete(
|
||||
job_id=job_id,
|
||||
status=_normalize_completion_status(
|
||||
status=validated_output.status,
|
||||
exit_code=exit_code,
|
||||
error_category=validated_output.error_category,
|
||||
),
|
||||
workspace_path=workspace_path,
|
||||
output_files=[
|
||||
file_ref.model_dump(mode="json") for file_ref in validated_output.output_files
|
||||
],
|
||||
error_category=_normalize_error_category(
|
||||
initial=validated_output.error_category,
|
||||
status=validated_output.status,
|
||||
exit_code=exit_code,
|
||||
),
|
||||
error_message=validated_output.error_message,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
exit_code=exit_code,
|
||||
duration_ms=duration_ms,
|
||||
finished_at=_now(),
|
||||
)
|
||||
return AgentJobResponse(job=job)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _normalize_completion_status(
|
||||
*,
|
||||
status: AgentJobStatus,
|
||||
exit_code: int | None,
|
||||
error_category: AgentJobErrorCategory | None,
|
||||
) -> AgentJobStatus:
|
||||
if status == AgentJobStatus.SUCCEEDED and exit_code not in (0, None):
|
||||
return AgentJobStatus.FAILED
|
||||
if error_category == AgentJobErrorCategory.CLI_EXIT_CODE_FAILURE and exit_code in (
|
||||
None,
|
||||
0,
|
||||
):
|
||||
return AgentJobStatus.SUCCEEDED
|
||||
return status
|
||||
|
||||
|
||||
def _normalize_error_category(
|
||||
*,
|
||||
initial: AgentJobErrorCategory | None,
|
||||
status: AgentJobStatus,
|
||||
exit_code: int | None,
|
||||
) -> AgentJobErrorCategory | None:
|
||||
if initial is not None:
|
||||
return initial
|
||||
if status == AgentJobStatus.SUCCEEDED and exit_code not in (0, None):
|
||||
return AgentJobErrorCategory.CLI_EXIT_CODE_FAILURE
|
||||
return None
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from src.domain.contracts import (
|
||||
ArticleCreateRequest,
|
||||
ArticleCreateResponse,
|
||||
ArticleDetailResponse,
|
||||
ArticleListResponse,
|
||||
ArticleWorkflowStatus,
|
||||
CurrentUser,
|
||||
PublishingStatus,
|
||||
)
|
||||
|
||||
|
||||
def create_article(
|
||||
repository: object,
|
||||
request: ArticleCreateRequest,
|
||||
current_user: CurrentUser,
|
||||
) -> ArticleCreateResponse:
|
||||
repository.target_sites.get_by_id(request.target_site_id)
|
||||
|
||||
now = _now()
|
||||
summary = repository.articles.create(
|
||||
target_site_id=request.target_site_id,
|
||||
status=ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
|
||||
publishing_status=PublishingStatus.PUBLISH_NOT_STARTED,
|
||||
brief_description=request.brief_description,
|
||||
working_title=request.working_title,
|
||||
language=request.language,
|
||||
content_type=request.content_type,
|
||||
primary_keyword=request.primary_keyword,
|
||||
assigned_editor_id=request.assigned_editor_id or current_user.id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
repository.articles.create_workflow_event(
|
||||
article_id=summary.id,
|
||||
event_type="ARTICLE_CREATED",
|
||||
from_status=None,
|
||||
to_status=ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
|
||||
actor_user_id=current_user.id,
|
||||
payload={"brief_description_length": len(request.brief_description)},
|
||||
created_at=now,
|
||||
)
|
||||
return ArticleCreateResponse(article=summary)
|
||||
|
||||
|
||||
def list_articles(repository: object) -> ArticleListResponse:
|
||||
return ArticleListResponse(articles=repository.articles.list())
|
||||
|
||||
|
||||
def get_article_detail(
|
||||
repository: object,
|
||||
article_id: UUID,
|
||||
) -> ArticleDetailResponse:
|
||||
article = repository.articles.get(article_id)
|
||||
target_site = repository.target_sites.get_by_id(article.target_site_id)
|
||||
workflow_events = repository.articles.list_workflow_events(article_id)
|
||||
boundary_questions = repository.boundary_questions.list_for_article(article_id)
|
||||
plans = repository.article_plans.list_for_article(article_id)
|
||||
research_manifests = repository.research_manifests.list_for_article(article_id)
|
||||
return ArticleDetailResponse(
|
||||
article=article,
|
||||
target_site=target_site,
|
||||
workflow_events=workflow_events,
|
||||
boundary_questions=boundary_questions,
|
||||
plan=plans[-1] if plans else None,
|
||||
research_manifests=research_manifests,
|
||||
)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.domain.contracts import CurrentUser
|
||||
|
||||
|
||||
def get_current_user_by_email(repository: object, email: str) -> CurrentUser:
|
||||
user = repository.users.get_by_email(email)
|
||||
return CurrentUser(
|
||||
id=user.id,
|
||||
email=email,
|
||||
display_name=user.display_name,
|
||||
role=user.role,
|
||||
)
|
||||
@@ -0,0 +1,265 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from src.domain.contracts import (
|
||||
AgentJobErrorCategory,
|
||||
AgentJobListResponse,
|
||||
AgentJobStatus,
|
||||
AgentJobType,
|
||||
ArticleSummary,
|
||||
ArticleWorkflowStatus,
|
||||
BoundaryQuestionListResponse,
|
||||
BoundaryQuestionResponse,
|
||||
BoundaryQuestionUpdateRequest,
|
||||
CurrentUser,
|
||||
)
|
||||
|
||||
|
||||
BOUNDARY_QUESTION_FIXTURE: tuple[dict[str, object], ...] = (
|
||||
{
|
||||
"category": "audience",
|
||||
"label": "Audience",
|
||||
"is_required": True,
|
||||
},
|
||||
{
|
||||
"category": "purpose",
|
||||
"label": "Purpose",
|
||||
"is_required": True,
|
||||
},
|
||||
{
|
||||
"category": "reader_outcome",
|
||||
"label": "Reader outcome",
|
||||
"is_required": True,
|
||||
},
|
||||
{
|
||||
"category": "depth",
|
||||
"label": "Depth",
|
||||
"is_required": True,
|
||||
},
|
||||
{
|
||||
"category": "tone",
|
||||
"label": "Tone",
|
||||
"is_required": True,
|
||||
},
|
||||
{
|
||||
"category": "excluded_topics",
|
||||
"label": "Excluded topics",
|
||||
"is_required": True,
|
||||
},
|
||||
{
|
||||
"category": "primary_keyword",
|
||||
"label": "Primary keyword",
|
||||
"is_required": True,
|
||||
},
|
||||
{
|
||||
"category": "competitor_angle",
|
||||
"label": "Competitor angle",
|
||||
"is_required": True,
|
||||
},
|
||||
{
|
||||
"category": "evidence_standard",
|
||||
"label": "Evidence standard",
|
||||
"is_required": True,
|
||||
},
|
||||
{
|
||||
"category": "visual_expectations",
|
||||
"label": "Visual expectations",
|
||||
"is_required": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def generate_boundary_questions(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
current_user: CurrentUser,
|
||||
) -> BoundaryQuestionListResponse:
|
||||
article = repository.articles.get(article_id)
|
||||
target_site = repository.target_sites.get_by_id(article.target_site_id)
|
||||
now = _now()
|
||||
previous_status = article.status
|
||||
|
||||
job = repository.agent_jobs.create(
|
||||
article_id=article_id,
|
||||
parent_job_id=None,
|
||||
attempt=1,
|
||||
job_type=AgentJobType.BOUNDARY_QUESTIONS,
|
||||
agent_profile="fake-boundary-questions",
|
||||
status=AgentJobStatus.QUEUED,
|
||||
input_files=[
|
||||
{
|
||||
"path": "inputs/article-brief.json",
|
||||
"content_hash": None,
|
||||
},
|
||||
{
|
||||
"path": "inputs/target-site-config.json",
|
||||
"content_hash": None,
|
||||
},
|
||||
],
|
||||
queued_at=now,
|
||||
)
|
||||
job = repository.agent_jobs.complete(
|
||||
job_id=job.id,
|
||||
status=AgentJobStatus.SUCCEEDED,
|
||||
workspace_path=None,
|
||||
output_files=[{"path": "outputs/boundary-questions.json"}],
|
||||
error_category=None,
|
||||
error_message=None,
|
||||
stdout="fake boundary question fixture generated\n",
|
||||
stderr="",
|
||||
exit_code=0,
|
||||
duration_ms=0,
|
||||
finished_at=now,
|
||||
)
|
||||
questions = repository.boundary_questions.replace_for_article(
|
||||
article_id=article_id,
|
||||
questions=_build_questions(article=article, target_site=target_site),
|
||||
created_at=now,
|
||||
)
|
||||
|
||||
article = repository.articles.update_status(
|
||||
article_id=article_id,
|
||||
status=ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED,
|
||||
updated_at=now,
|
||||
)
|
||||
repository.articles.create_workflow_event(
|
||||
article_id=article.id,
|
||||
event_type="BOUNDARY_QUESTIONS_GENERATED",
|
||||
from_status=previous_status,
|
||||
to_status=ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED,
|
||||
actor_user_id=current_user.id,
|
||||
payload={"agent_job_id": str(job.id), "question_count": len(questions)},
|
||||
created_at=now,
|
||||
)
|
||||
|
||||
return BoundaryQuestionListResponse(questions=questions, job=job)
|
||||
|
||||
|
||||
def list_boundary_questions(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
) -> BoundaryQuestionListResponse:
|
||||
repository.articles.get(article_id)
|
||||
return BoundaryQuestionListResponse(
|
||||
questions=repository.boundary_questions.list_for_article(article_id),
|
||||
)
|
||||
|
||||
|
||||
def update_boundary_question(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
question_id: UUID,
|
||||
request: BoundaryQuestionUpdateRequest,
|
||||
) -> BoundaryQuestionResponse:
|
||||
repository.articles.get(article_id)
|
||||
answer = request.answer.strip() if request.answer is not None else None
|
||||
question = repository.boundary_questions.update_answer(
|
||||
article_id=article_id,
|
||||
question_id=question_id,
|
||||
answer=answer or None,
|
||||
updated_at=_now(),
|
||||
)
|
||||
return BoundaryQuestionResponse(question=question)
|
||||
|
||||
|
||||
def submit_boundary_questions(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
current_user: CurrentUser,
|
||||
) -> ArticleSummary:
|
||||
article = repository.articles.get(article_id)
|
||||
questions = repository.boundary_questions.list_for_article(article_id)
|
||||
if not questions:
|
||||
raise ValueError("Boundary questions must be generated before submission")
|
||||
|
||||
missing_required = [
|
||||
question.category
|
||||
for question in questions
|
||||
if question.is_required and not (question.answer or "").strip()
|
||||
]
|
||||
if missing_required:
|
||||
raise ValueError(
|
||||
"All required boundary questions must be answered: "
|
||||
+ ", ".join(missing_required)
|
||||
)
|
||||
|
||||
now = _now()
|
||||
updated_article = repository.articles.update_status(
|
||||
article_id=article_id,
|
||||
status=ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
|
||||
updated_at=now,
|
||||
)
|
||||
repository.articles.create_workflow_event(
|
||||
article_id=article_id,
|
||||
event_type="BOUNDARY_ANSWERS_SUBMITTED",
|
||||
from_status=article.status,
|
||||
to_status=ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
|
||||
actor_user_id=current_user.id,
|
||||
payload={"required_answer_count": len(questions) - len(missing_required)},
|
||||
created_at=now,
|
||||
)
|
||||
return updated_article
|
||||
|
||||
|
||||
def create_plan_generation_job(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
) -> AgentJobListResponse:
|
||||
article = repository.articles.get(article_id)
|
||||
if article.status != ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED:
|
||||
raise PermissionError("Boundary answers must be submitted before plan generation")
|
||||
|
||||
job = repository.agent_jobs.create(
|
||||
article_id=article_id,
|
||||
parent_job_id=None,
|
||||
attempt=1,
|
||||
job_type=AgentJobType.PLAN_GENERATION,
|
||||
agent_profile="fake-plan-generation",
|
||||
status=AgentJobStatus.QUEUED,
|
||||
input_files=[{"path": "inputs/boundary-answers.json", "content_hash": None}],
|
||||
queued_at=_now(),
|
||||
)
|
||||
return AgentJobListResponse(jobs=[job])
|
||||
|
||||
|
||||
def _build_questions(*, article: ArticleSummary, target_site: object) -> list[dict[str, object]]:
|
||||
title = article.working_title or article.primary_keyword or article.content_type
|
||||
site_audience = getattr(target_site, "audience")
|
||||
brand_voice = getattr(target_site, "brand_voice")
|
||||
primary_keyword = article.primary_keyword or "the primary topic"
|
||||
brief = article.brief_description
|
||||
|
||||
prompts = {
|
||||
"audience": f"Who exactly is this article for on {site_audience}?",
|
||||
"purpose": f"What business or editorial purpose should '{title}' serve?",
|
||||
"reader_outcome": "What should the reader be able to decide or do after reading?",
|
||||
"depth": f"How deep should coverage go for this {article.content_type} brief?",
|
||||
"tone": f"What tone should adapt the site voice '{brand_voice}' for this brief?",
|
||||
"excluded_topics": "Which topics, claims, or comparisons must be excluded?",
|
||||
"primary_keyword": f"How should the keyword '{primary_keyword}' be prioritized?",
|
||||
"competitor_angle": "Which competitor angle should the article address or avoid?",
|
||||
"evidence_standard": "What evidence standard is required for claims in this article?",
|
||||
"visual_expectations": "What diagrams, tables, or images would clarify the brief?",
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
"sort_order": index,
|
||||
"category": str(item["category"]),
|
||||
"question": f"{item['label']}: {prompts[str(item['category'])]} Brief: {brief}",
|
||||
"answer": None,
|
||||
"is_required": bool(item["is_required"]),
|
||||
}
|
||||
for index, item in enumerate(BOUNDARY_QUESTION_FIXTURE, start=1)
|
||||
]
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from src.domain.contracts import (
|
||||
AgentJobStatus,
|
||||
AgentJobType,
|
||||
ArticleWorkflowStatus,
|
||||
PlanReviewStatus,
|
||||
ResearchListResponse,
|
||||
ResearchStartResponse,
|
||||
)
|
||||
from src.infrastructure.object_storage import ObjectStorageClient
|
||||
|
||||
|
||||
def start_research_run(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
object_storage: ObjectStorageClient,
|
||||
) -> ResearchStartResponse:
|
||||
article = repository.articles.get(article_id)
|
||||
approved_plan = _approved_plan(repository, article_id)
|
||||
if approved_plan is None:
|
||||
raise PermissionError("Plan must be approved before research can start")
|
||||
|
||||
now = _now()
|
||||
if article.status != ArticleWorkflowStatus.RESEARCH_RUNNING:
|
||||
article = repository.articles.update_status(
|
||||
article_id=article_id,
|
||||
status=ArticleWorkflowStatus.RESEARCH_RUNNING,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
job = repository.agent_jobs.create(
|
||||
article_id=article_id,
|
||||
parent_job_id=None,
|
||||
attempt=1,
|
||||
job_type=AgentJobType.RESEARCH,
|
||||
agent_profile="fake-research-fetch",
|
||||
status=AgentJobStatus.SUCCEEDED,
|
||||
input_files=[{"path": "inputs/approved-plan.json", "content_hash": None}],
|
||||
queued_at=now,
|
||||
)
|
||||
run_id = uuid4()
|
||||
s3_prefix = f"research/{article_id}/{run_id}"
|
||||
artifacts = _build_fake_research_artifacts(
|
||||
article=article,
|
||||
plan=approved_plan,
|
||||
s3_prefix=s3_prefix,
|
||||
object_storage=object_storage,
|
||||
retrieved_at=now,
|
||||
)
|
||||
manifest = repository.research_manifests.create(
|
||||
article_id=article_id,
|
||||
agent_job_id=job.id,
|
||||
s3_prefix=s3_prefix,
|
||||
artifacts=artifacts,
|
||||
created_at=now,
|
||||
)
|
||||
job = repository.agent_jobs.complete(
|
||||
job_id=job.id,
|
||||
status=AgentJobStatus.SUCCEEDED,
|
||||
workspace_path=None,
|
||||
output_files=[{"path": artifact["object_key"]} for artifact in artifacts],
|
||||
error_category=None,
|
||||
error_message=None,
|
||||
stdout="fake research fetch completed\n",
|
||||
stderr="",
|
||||
exit_code=0,
|
||||
duration_ms=0,
|
||||
finished_at=now,
|
||||
)
|
||||
return ResearchStartResponse(article=article, job=job, manifest=manifest)
|
||||
|
||||
|
||||
def list_research_runs(repository: object, *, article_id: UUID) -> ResearchListResponse:
|
||||
repository.articles.get(article_id)
|
||||
return ResearchListResponse(
|
||||
manifests=repository.research_manifests.list_for_article(article_id),
|
||||
)
|
||||
|
||||
|
||||
def _approved_plan(repository: object, article_id: UUID) -> object | None:
|
||||
for plan in reversed(repository.article_plans.list_for_article(article_id)):
|
||||
if plan.status == PlanReviewStatus.APPROVED:
|
||||
return plan
|
||||
return None
|
||||
|
||||
|
||||
def _build_fake_research_artifacts(
|
||||
*,
|
||||
article: object,
|
||||
plan: object,
|
||||
s3_prefix: str,
|
||||
object_storage: ObjectStorageClient,
|
||||
retrieved_at: datetime,
|
||||
) -> list[dict[str, object]]:
|
||||
sources = [
|
||||
{
|
||||
"url": "https://example.com/research/content-governance",
|
||||
"title": "Content governance implementation guide",
|
||||
"source_type": "primary_reference",
|
||||
"summary": "Governance programs need explicit review gates and source evidence.",
|
||||
"section": "A source-section snapshot about governance workflow controls.",
|
||||
"ip": "93.184.216.34",
|
||||
"whois": "Example Domain Registry",
|
||||
},
|
||||
{
|
||||
"url": "https://example.org/research/evidence-standards",
|
||||
"title": "Evidence standards for editorial claims",
|
||||
"source_type": "standards_reference",
|
||||
"summary": "Editorial claims should be linked to durable source artifacts.",
|
||||
"section": "A source-section snapshot about evidence standards and claim review.",
|
||||
"ip": "93.184.216.34",
|
||||
"whois": "Example Organization Registry",
|
||||
},
|
||||
]
|
||||
|
||||
artifacts: list[dict[str, object]] = []
|
||||
for index, source in enumerate(sources, start=1):
|
||||
parsed = urlparse(str(source["url"]))
|
||||
base_key = f"{s3_prefix}/source-{index}"
|
||||
content_key = f"{base_key}/section.json"
|
||||
metadata_key = f"{base_key}/metadata.json"
|
||||
payload = {
|
||||
"article_id": str(article.id),
|
||||
"plan_id": str(plan.id),
|
||||
"plan_version": plan.version,
|
||||
"source_url": source["url"],
|
||||
"section_snapshot": source["section"],
|
||||
"retrieved_at": retrieved_at.isoformat(),
|
||||
}
|
||||
metadata = {
|
||||
"source_title": source["title"],
|
||||
"domain": parsed.netloc,
|
||||
"source_type": source["source_type"],
|
||||
"summary": source["summary"],
|
||||
"retrieved_at": retrieved_at.isoformat(),
|
||||
"artifact_link": content_key,
|
||||
"metadata_object_key": metadata_key,
|
||||
"search_path": [
|
||||
article.primary_keyword or article.working_title or "article research",
|
||||
str(source["title"]),
|
||||
],
|
||||
"selection_criteria": [
|
||||
"Matches approved plan evidence needs",
|
||||
"Deterministic fake fixture for demo and tests",
|
||||
],
|
||||
"head_metadata": {
|
||||
"title": source["title"],
|
||||
"description": source["summary"],
|
||||
"canonical": source["url"],
|
||||
},
|
||||
"server_ip_address": source["ip"],
|
||||
"whois_owner": source["whois"],
|
||||
}
|
||||
content = json.dumps(payload, sort_keys=True, indent=2)
|
||||
metadata_content = json.dumps(metadata, sort_keys=True, indent=2)
|
||||
object_storage.put_text(object_key=content_key, content=content)
|
||||
object_storage.put_text(object_key=metadata_key, content=metadata_content)
|
||||
artifacts.append(
|
||||
{
|
||||
"artifact_type": "source_section_snapshot",
|
||||
"source_url": source["url"],
|
||||
"object_key": content_key,
|
||||
"content_hash": hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
||||
"metadata": metadata,
|
||||
}
|
||||
)
|
||||
|
||||
return artifacts
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
from uuid import NAMESPACE_URL, UUID, uuid5
|
||||
|
||||
from src.domain.contracts import (
|
||||
PublishingRules,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
)
|
||||
from src.domain.schema import (
|
||||
SEEDED_ADMIN_EMAIL,
|
||||
SEEDED_EDITOR_EMAIL,
|
||||
SEEDED_TARGET_SITE_SLUG,
|
||||
)
|
||||
|
||||
|
||||
SEED_TIMESTAMP = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
def seed_reference_data(repository: object) -> None:
|
||||
admin_id = _stable_uuid("user:admin")
|
||||
editor_id = _stable_uuid("user:editor")
|
||||
target_site_id = _stable_uuid(f"target-site:{SEEDED_TARGET_SITE_SLUG}")
|
||||
script_config_version_id = _stable_uuid(
|
||||
f"script-config-version:{SEEDED_TARGET_SITE_SLUG}:1"
|
||||
)
|
||||
|
||||
repository.users.upsert(
|
||||
user_id=admin_id,
|
||||
email=SEEDED_ADMIN_EMAIL,
|
||||
display_name="Admin User",
|
||||
role=Role.ADMIN,
|
||||
created_at=SEED_TIMESTAMP,
|
||||
updated_at=SEED_TIMESTAMP,
|
||||
)
|
||||
repository.users.upsert(
|
||||
user_id=editor_id,
|
||||
email=SEEDED_EDITOR_EMAIL,
|
||||
display_name="Editor User",
|
||||
role=Role.EDITOR,
|
||||
created_at=SEED_TIMESTAMP,
|
||||
updated_at=SEED_TIMESTAMP,
|
||||
)
|
||||
|
||||
publishing_rules = PublishingRules(
|
||||
repository_url="git@github.com:example/site.git",
|
||||
production_branch="main",
|
||||
content_format="mdx",
|
||||
content_path_template="content/articles/{slug}.mdx",
|
||||
asset_path_template="public/articles/{slug}/{filename}",
|
||||
frontmatter_mapping={
|
||||
"title": "title",
|
||||
"description": "meta_description",
|
||||
"publishedAt": "published_at",
|
||||
},
|
||||
transform_script_version_id=script_config_version_id,
|
||||
dry_run_renderer="next-mdx",
|
||||
)
|
||||
repository.target_sites.upsert(
|
||||
site_id=target_site_id,
|
||||
name="B2B SaaS Blog",
|
||||
slug=SEEDED_TARGET_SITE_SLUG,
|
||||
publishing_type="git_next",
|
||||
default_language="en",
|
||||
brand_voice="Clear, practical, evidence-led B2B editorial voice.",
|
||||
audience="B2B SaaS founders and growth teams.",
|
||||
seo_rules={
|
||||
"title_max_length": 60,
|
||||
"meta_description_max_length": 155,
|
||||
"primary_keyword_required": True,
|
||||
},
|
||||
visual_rules={
|
||||
"hero_style": "product editorial",
|
||||
"diagram_format": "mermaid",
|
||||
},
|
||||
source_rules={
|
||||
"minimum_sources": 3,
|
||||
"preferred_source_types": ["primary", "analyst", "vendor_docs"],
|
||||
},
|
||||
publishing_rules=publishing_rules,
|
||||
active_script_config_version_id=script_config_version_id,
|
||||
created_at=SEED_TIMESTAMP,
|
||||
updated_at=SEED_TIMESTAMP,
|
||||
)
|
||||
|
||||
publishing_yaml = """target: b2b_saas_blog
|
||||
repository:
|
||||
url: git@github.com:example/site.git
|
||||
branch: main
|
||||
content:
|
||||
format: mdx
|
||||
path_template: content/articles/{slug}.mdx
|
||||
assets:
|
||||
path_template: public/articles/{slug}/{filename}
|
||||
"""
|
||||
transform_script = """export function transformArticle(article) {
|
||||
return {
|
||||
frontmatter: article.frontmatter,
|
||||
body: article.body,
|
||||
assets: article.assets,
|
||||
};
|
||||
}
|
||||
"""
|
||||
repository.script_config_versions.upsert(
|
||||
version_id=script_config_version_id,
|
||||
target_site_id=target_site_id,
|
||||
version=1,
|
||||
status=ScriptConfigVersionStatus.ACTIVE,
|
||||
created_by=admin_id,
|
||||
created_at=SEED_TIMESTAMP,
|
||||
updated_at=SEED_TIMESTAMP,
|
||||
diff={"summary": "Initial Git-backed Next.js publishing configuration."},
|
||||
rollback_target_version_id=None,
|
||||
activated_at=SEED_TIMESTAMP,
|
||||
publishing_yaml=publishing_yaml,
|
||||
publishing_yaml_hash=_sha256(publishing_yaml),
|
||||
transform_script=transform_script,
|
||||
transform_script_hash=_sha256(transform_script),
|
||||
)
|
||||
|
||||
|
||||
def _stable_uuid(value: str) -> UUID:
|
||||
return uuid5(NAMESPACE_URL, f"ai-content-pipeline:{value}")
|
||||
|
||||
|
||||
def _sha256(value: str) -> str:
|
||||
return sha256(value.encode("utf-8")).hexdigest()
|
||||
@@ -0,0 +1,266 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
from uuid import NAMESPACE_URL, UUID, uuid5
|
||||
|
||||
from src.domain.contracts import (
|
||||
CurrentUser,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionResponse,
|
||||
ScriptConfigVersionStatus,
|
||||
ScriptConfigVersionSummary,
|
||||
TargetSiteConfigCreateRequest,
|
||||
TargetSiteConfigResponse,
|
||||
TargetSiteConfigUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
_VERSION_CREATED_EVENT_TYPE = "SCRIPT_CONFIG_VERSION_CREATED"
|
||||
_VERSION_ACTIVATED_EVENT_TYPE = "SCRIPT_CONFIG_VERSION_ACTIVATED"
|
||||
_VERSION_ROLLBACK_EVENT_TYPE = "SCRIPT_CONFIG_VERSION_ROLLBACK"
|
||||
|
||||
|
||||
def create_target_site(
|
||||
repository: object,
|
||||
request: TargetSiteConfigCreateRequest,
|
||||
) -> TargetSiteConfigResponse:
|
||||
now = _now()
|
||||
site = repository.target_sites.upsert(
|
||||
site_id=_stable_uuid(f"target-site:{request.slug}"),
|
||||
name=request.name,
|
||||
slug=request.slug,
|
||||
publishing_type=request.publishing_type,
|
||||
default_language=request.default_language,
|
||||
brand_voice=request.brand_voice,
|
||||
audience=request.audience,
|
||||
seo_rules=request.seo_rules,
|
||||
visual_rules=request.visual_rules,
|
||||
source_rules=request.source_rules,
|
||||
publishing_rules=request.publishing_rules,
|
||||
active_script_config_version_id=request.active_script_config_version_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
return TargetSiteConfigResponse(site=site)
|
||||
|
||||
|
||||
def get_target_site(
|
||||
repository: object,
|
||||
site_id: UUID,
|
||||
) -> TargetSiteConfigResponse:
|
||||
site = repository.target_sites.get_by_id(site_id)
|
||||
return TargetSiteConfigResponse(site=site)
|
||||
|
||||
|
||||
def update_target_site(
|
||||
repository: object,
|
||||
site_id: UUID,
|
||||
request: TargetSiteConfigUpdateRequest,
|
||||
) -> TargetSiteConfigResponse:
|
||||
existing = repository.target_sites.get_by_id(site_id)
|
||||
changes = request.model_dump(exclude_unset=True)
|
||||
publishing_rules = (
|
||||
request.publishing_rules
|
||||
if "publishing_rules" in changes
|
||||
else existing.publishing_rules
|
||||
)
|
||||
now = _now()
|
||||
site = repository.target_sites.update(
|
||||
site_id=existing.id,
|
||||
name=changes.get("name", existing.name),
|
||||
slug=changes.get("slug", existing.slug),
|
||||
publishing_type=changes.get("publishing_type", existing.publishing_type),
|
||||
default_language=changes.get("default_language", existing.default_language),
|
||||
brand_voice=changes.get("brand_voice", existing.brand_voice),
|
||||
audience=changes.get("audience", existing.audience),
|
||||
seo_rules=changes.get("seo_rules", existing.seo_rules),
|
||||
visual_rules=changes.get("visual_rules", existing.visual_rules),
|
||||
source_rules=changes.get("source_rules", existing.source_rules),
|
||||
publishing_rules=publishing_rules,
|
||||
active_script_config_version_id=changes.get(
|
||||
"active_script_config_version_id",
|
||||
existing.active_script_config_version_id,
|
||||
),
|
||||
updated_at=now,
|
||||
)
|
||||
return TargetSiteConfigResponse(site=site)
|
||||
|
||||
|
||||
def create_script_config_version(
|
||||
repository: object,
|
||||
*,
|
||||
site_id: UUID,
|
||||
current_user: CurrentUser,
|
||||
request: ScriptConfigVersionCreateRequest,
|
||||
) -> ScriptConfigVersionResponse:
|
||||
repository.target_sites.get_by_id(site_id)
|
||||
|
||||
if request.rollback_target_version_id is not None:
|
||||
rollback_target = repository.script_config_versions.get_by_id(
|
||||
request.rollback_target_version_id
|
||||
)
|
||||
if str(rollback_target["target_site_id"]) != str(site_id):
|
||||
raise LookupError(
|
||||
f"Rollback target version is not for site: {request.rollback_target_version_id}"
|
||||
)
|
||||
|
||||
existing_versions = repository.script_config_versions.list_for_site(site_id)
|
||||
version = request.version or _next_version(existing_versions)
|
||||
now = _now()
|
||||
version_id = _stable_uuid(f"script-config-version:{site_id}:{version}")
|
||||
status = (
|
||||
ScriptConfigVersionStatus.ACTIVE
|
||||
if request.activate
|
||||
else ScriptConfigVersionStatus.DRAFT
|
||||
)
|
||||
row = repository.script_config_versions.upsert(
|
||||
version_id=version_id,
|
||||
target_site_id=site_id,
|
||||
version=version,
|
||||
status=status,
|
||||
created_by=current_user.id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
diff=request.diff,
|
||||
rollback_target_version_id=request.rollback_target_version_id,
|
||||
activated_at=now if request.activate else None,
|
||||
publishing_yaml=request.publishing_yaml,
|
||||
publishing_yaml_hash=_sha256(request.publishing_yaml),
|
||||
transform_script=request.transform_script,
|
||||
transform_script_hash=_sha256(request.transform_script),
|
||||
)
|
||||
if request.activate:
|
||||
row = repository.script_config_versions.activate(
|
||||
target_site_id=site_id,
|
||||
version_id=version_id,
|
||||
activated_at=now,
|
||||
rollback_target_version_id=request.rollback_target_version_id,
|
||||
)
|
||||
repository.script_config_version_events.create(
|
||||
target_site_id=site_id,
|
||||
version_id=version_id,
|
||||
event_type=_VERSION_ACTIVATED_EVENT_TYPE,
|
||||
actor_user_id=current_user.id,
|
||||
payload={"action": "activate", "version": version},
|
||||
created_at=now,
|
||||
)
|
||||
else:
|
||||
repository.script_config_version_events.create(
|
||||
target_site_id=site_id,
|
||||
version_id=version_id,
|
||||
event_type=_VERSION_CREATED_EVENT_TYPE,
|
||||
actor_user_id=current_user.id,
|
||||
payload={"action": "create", "version": version},
|
||||
created_at=now,
|
||||
)
|
||||
|
||||
return ScriptConfigVersionResponse(version=_script_version_summary(row))
|
||||
|
||||
|
||||
def activate_script_config_version(
|
||||
repository: object,
|
||||
*,
|
||||
site_id: UUID,
|
||||
version_id: UUID,
|
||||
current_user: CurrentUser,
|
||||
) -> ScriptConfigVersionResponse:
|
||||
repository.target_sites.get_by_id(site_id)
|
||||
row = repository.script_config_versions.activate(
|
||||
target_site_id=site_id,
|
||||
version_id=version_id,
|
||||
activated_at=_now(),
|
||||
)
|
||||
repository.script_config_version_events.create(
|
||||
target_site_id=site_id,
|
||||
version_id=version_id,
|
||||
event_type=_VERSION_ACTIVATED_EVENT_TYPE,
|
||||
actor_user_id=current_user.id,
|
||||
payload={"action": "activate", "version_id": str(version_id)},
|
||||
created_at=_now(),
|
||||
)
|
||||
return ScriptConfigVersionResponse(version=_script_version_summary(row))
|
||||
|
||||
|
||||
def rollback_script_config_version(
|
||||
repository: object,
|
||||
*,
|
||||
site_id: UUID,
|
||||
version_id: UUID,
|
||||
current_user: CurrentUser,
|
||||
) -> ScriptConfigVersionResponse:
|
||||
target_site = repository.target_sites.get_by_id(site_id)
|
||||
version_row = repository.script_config_versions.get_by_id(version_id)
|
||||
if str(version_row["target_site_id"]) != str(site_id):
|
||||
raise LookupError(
|
||||
f"Script config version not found for site: {site_id} {version_id}"
|
||||
)
|
||||
|
||||
rollback_target_id = target_site.active_script_config_version_id
|
||||
if rollback_target_id == version_id:
|
||||
rollback_target_id = None
|
||||
|
||||
row = repository.script_config_versions.activate(
|
||||
target_site_id=site_id,
|
||||
version_id=version_id,
|
||||
activated_at=_now(),
|
||||
rollback_target_version_id=rollback_target_id,
|
||||
)
|
||||
repository.script_config_version_events.create(
|
||||
target_site_id=site_id,
|
||||
version_id=version_id,
|
||||
event_type=_VERSION_ROLLBACK_EVENT_TYPE,
|
||||
actor_user_id=current_user.id,
|
||||
payload={
|
||||
"action": "rollback",
|
||||
"rollback_target_version_id": (
|
||||
str(rollback_target_id) if rollback_target_id is not None else None
|
||||
),
|
||||
},
|
||||
created_at=_now(),
|
||||
)
|
||||
return ScriptConfigVersionResponse(version=_script_version_summary(row))
|
||||
|
||||
|
||||
def list_script_config_versions(
|
||||
repository: object,
|
||||
site_id: UUID,
|
||||
) -> list[ScriptConfigVersionSummary]:
|
||||
repository.target_sites.get_by_id(site_id)
|
||||
rows = repository.script_config_versions.list_for_site(site_id)
|
||||
return [_script_version_summary(row) for row in rows]
|
||||
|
||||
|
||||
def _script_version_summary(row: dict[str, object]) -> ScriptConfigVersionSummary:
|
||||
return ScriptConfigVersionSummary(
|
||||
id=row["id"],
|
||||
target_site_id=row["target_site_id"],
|
||||
version=row["version"],
|
||||
status=row["status"],
|
||||
diff=row["diff"],
|
||||
rollback_target_version_id=row["rollback_target_version_id"],
|
||||
publishing_yaml_hash=row["publishing_yaml_hash"],
|
||||
transform_script_hash=row["transform_script_hash"],
|
||||
created_by=row["created_by"],
|
||||
created_at=row["created_at"],
|
||||
activated_at=row["activated_at"],
|
||||
)
|
||||
|
||||
|
||||
def _next_version(rows: list[dict[str, object]]) -> int:
|
||||
versions = [int(row["version"]) for row in rows]
|
||||
if not versions:
|
||||
return 1
|
||||
return max(versions) + 1
|
||||
|
||||
|
||||
def _stable_uuid(value: str) -> UUID:
|
||||
return uuid5(NAMESPACE_URL, f"ai-content-pipeline:{value}")
|
||||
|
||||
|
||||
def _sha256(value: str) -> str:
|
||||
return sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
@@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.domain.contracts.enums import Role
|
||||
|
||||
|
||||
ADMIN_ROLES = frozenset({Role.ADMIN})
|
||||
EDITOR_OR_ADMIN_ROLES = frozenset({Role.ADMIN, Role.EDITOR})
|
||||
@@ -0,0 +1,135 @@
|
||||
from .enums import (
|
||||
AgentJobErrorCategory,
|
||||
AgentJobStatus,
|
||||
AgentJobType,
|
||||
ArticleWorkflowStatus,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
ClaimRiskLevel,
|
||||
ClaimSupportStatus,
|
||||
PlanReviewStatus,
|
||||
PublishingStatus,
|
||||
ReviewStatus,
|
||||
ReviewType,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
)
|
||||
from .models import (
|
||||
AgentJobOutput,
|
||||
AgentJobListResponse,
|
||||
AgentJobResponse,
|
||||
AgentJobSummary,
|
||||
AgentJobTestCodexRequest,
|
||||
ArticleCreateRequest,
|
||||
ArticleCreateResponse,
|
||||
ArticleDetailResponse,
|
||||
ArticleListResponse,
|
||||
ArticleSummary,
|
||||
BoundaryQuestionListResponse,
|
||||
BoundaryQuestionResponse,
|
||||
BoundaryQuestionSummary,
|
||||
BoundaryQuestionUpdateRequest,
|
||||
WorkflowEventSummary,
|
||||
AssetSummary,
|
||||
ClaimSummary,
|
||||
CurrentUser,
|
||||
CurrentUserResponse,
|
||||
DraftSummary,
|
||||
EvidenceSummary,
|
||||
PlanSectionSummary,
|
||||
PlanSectionEditRequest,
|
||||
PlanListResponse,
|
||||
PlanResponse,
|
||||
PlanRevisionRequest,
|
||||
PlanSummary,
|
||||
PlanUpdateRequest,
|
||||
PublishCommitSummary,
|
||||
PublishingRules,
|
||||
ResearchArtifactManifestSummary,
|
||||
ResearchArtifactSummary,
|
||||
ResearchListResponse,
|
||||
ResearchStartResponse,
|
||||
ReviewActionResponse,
|
||||
ReviewSummary,
|
||||
RunnerFileRef,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionListResponse,
|
||||
ScriptConfigVersionResponse,
|
||||
ScriptConfigVersionSummary,
|
||||
TargetSiteConfig,
|
||||
TargetSiteConfigCreateRequest,
|
||||
TargetSiteConfigResponse,
|
||||
TargetSiteConfigUpdateRequest,
|
||||
UserSummary,
|
||||
)
|
||||
from .workflow import (
|
||||
ARTICLE_WORKFLOW_STATUSES,
|
||||
ARTICLE_WORKFLOW_TRANSITIONS,
|
||||
next_workflow_statuses,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ARTICLE_WORKFLOW_STATUSES",
|
||||
"ARTICLE_WORKFLOW_TRANSITIONS",
|
||||
"AgentJobErrorCategory",
|
||||
"AgentJobListResponse",
|
||||
"AgentJobOutput",
|
||||
"AgentJobResponse",
|
||||
"AgentJobStatus",
|
||||
"AgentJobSummary",
|
||||
"AgentJobTestCodexRequest",
|
||||
"AgentJobType",
|
||||
"ArticleCreateRequest",
|
||||
"ArticleCreateResponse",
|
||||
"ArticleDetailResponse",
|
||||
"ArticleListResponse",
|
||||
"ArticleSummary",
|
||||
"BoundaryQuestionListResponse",
|
||||
"BoundaryQuestionResponse",
|
||||
"BoundaryQuestionSummary",
|
||||
"BoundaryQuestionUpdateRequest",
|
||||
"WorkflowEventSummary",
|
||||
"ArticleWorkflowStatus",
|
||||
"AssetStatus",
|
||||
"AssetSummary",
|
||||
"AssetType",
|
||||
"ClaimRiskLevel",
|
||||
"ClaimSummary",
|
||||
"ClaimSupportStatus",
|
||||
"CurrentUser",
|
||||
"CurrentUserResponse",
|
||||
"DraftSummary",
|
||||
"EvidenceSummary",
|
||||
"PlanReviewStatus",
|
||||
"PlanListResponse",
|
||||
"PlanResponse",
|
||||
"PlanRevisionRequest",
|
||||
"PlanSectionEditRequest",
|
||||
"PlanSectionSummary",
|
||||
"PlanSummary",
|
||||
"PlanUpdateRequest",
|
||||
"PublishCommitSummary",
|
||||
"PublishingRules",
|
||||
"PublishingStatus",
|
||||
"ResearchArtifactManifestSummary",
|
||||
"ResearchArtifactSummary",
|
||||
"ResearchListResponse",
|
||||
"ResearchStartResponse",
|
||||
"ReviewActionResponse",
|
||||
"ReviewStatus",
|
||||
"ReviewSummary",
|
||||
"ReviewType",
|
||||
"Role",
|
||||
"RunnerFileRef",
|
||||
"ScriptConfigVersionCreateRequest",
|
||||
"ScriptConfigVersionListResponse",
|
||||
"ScriptConfigVersionResponse",
|
||||
"ScriptConfigVersionStatus",
|
||||
"ScriptConfigVersionSummary",
|
||||
"TargetSiteConfig",
|
||||
"TargetSiteConfigCreateRequest",
|
||||
"TargetSiteConfigResponse",
|
||||
"TargetSiteConfigUpdateRequest",
|
||||
"UserSummary",
|
||||
"next_workflow_statuses",
|
||||
]
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Role(str, Enum):
|
||||
ADMIN = "ADMIN"
|
||||
EDITOR = "EDITOR"
|
||||
|
||||
|
||||
class ArticleWorkflowStatus(str, Enum):
|
||||
ARTICLE_BRIEF_CREATED = "ARTICLE_BRIEF_CREATED"
|
||||
BOUNDARY_QUESTIONS_GENERATED = "BOUNDARY_QUESTIONS_GENERATED"
|
||||
BOUNDARY_ANSWERS_SUBMITTED = "BOUNDARY_ANSWERS_SUBMITTED"
|
||||
PLAN_GENERATED = "PLAN_GENERATED"
|
||||
PLAN_REVIEW_REQUIRED = "PLAN_REVIEW_REQUIRED"
|
||||
PLAN_REVISION_REQUIRED = "PLAN_REVISION_REQUIRED"
|
||||
RESEARCH_RUNNING = "RESEARCH_RUNNING"
|
||||
EVIDENCE_MATRIX_READY = "EVIDENCE_MATRIX_READY"
|
||||
PARALLEL_PRODUCTION_RUNNING = "PARALLEL_PRODUCTION_RUNNING"
|
||||
DRAFT_ASSEMBLED = "DRAFT_ASSEMBLED"
|
||||
SEO_AND_LANGUAGE_REVIEW_READY = "SEO_AND_LANGUAGE_REVIEW_READY"
|
||||
FINAL_REVIEW_REQUIRED = "FINAL_REVIEW_REQUIRED"
|
||||
FINAL_REVISION_REQUIRED = "FINAL_REVISION_REQUIRED"
|
||||
PUBLISH_DRY_RUN_REQUIRED = "PUBLISH_DRY_RUN_REQUIRED"
|
||||
PUBLISH_COMMIT_READY = "PUBLISH_COMMIT_READY"
|
||||
PUBLISH_COMMIT_CREATED = "PUBLISH_COMMIT_CREATED"
|
||||
|
||||
|
||||
class AgentJobStatus(str, Enum):
|
||||
QUEUED = "QUEUED"
|
||||
RUNNING = "RUNNING"
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
FAILED = "FAILED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
class AgentJobType(str, Enum):
|
||||
BOUNDARY_QUESTIONS = "BOUNDARY_QUESTIONS"
|
||||
PLAN_GENERATION = "PLAN_GENERATION"
|
||||
RESEARCH = "RESEARCH"
|
||||
EVIDENCE_MATRIX = "EVIDENCE_MATRIX"
|
||||
SECTION_SCAFFOLD = "SECTION_SCAFFOLD"
|
||||
DRAFT_ASSEMBLY = "DRAFT_ASSEMBLY"
|
||||
SEO_REVIEW = "SEO_REVIEW"
|
||||
LANGUAGE_REVIEW = "LANGUAGE_REVIEW"
|
||||
PUBLISH_DRY_RUN = "PUBLISH_DRY_RUN"
|
||||
PUBLISH_COMMIT = "PUBLISH_COMMIT"
|
||||
TEST_CODEX = "TEST_CODEX"
|
||||
|
||||
|
||||
class AgentJobErrorCategory(str, Enum):
|
||||
FAILED_SCHEMA_VALIDATION = "FAILED_SCHEMA_VALIDATION"
|
||||
CLI_EXIT_CODE_FAILURE = "CLI_EXIT_CODE_FAILURE"
|
||||
TIMEOUT = "TIMEOUT"
|
||||
MISSING_OUTPUT_FILE = "MISSING_OUTPUT_FILE"
|
||||
UNSUPPORTED_CLAIMS_FOUND = "UNSUPPORTED_CLAIMS_FOUND"
|
||||
SOURCE_RETRIEVAL_FAILED = "SOURCE_RETRIEVAL_FAILED"
|
||||
RESEARCH_ARTIFACT_UPLOAD_FAILED = "RESEARCH_ARTIFACT_UPLOAD_FAILED"
|
||||
PUBLISH_DRY_RUN_FAILED = "PUBLISH_DRY_RUN_FAILED"
|
||||
GIT_CHECKOUT_FAILED = "GIT_CHECKOUT_FAILED"
|
||||
GIT_COMMIT_FAILED = "GIT_COMMIT_FAILED"
|
||||
GIT_PUSH_NON_FAST_FORWARD = "GIT_PUSH_NON_FAST_FORWARD"
|
||||
PUBLISH_VERIFICATION_FAILED = "PUBLISH_VERIFICATION_FAILED"
|
||||
|
||||
|
||||
class ClaimSupportStatus(str, Enum):
|
||||
SUPPORTED = "SUPPORTED"
|
||||
UNSUPPORTED = "UNSUPPORTED"
|
||||
NEEDS_REVIEW = "NEEDS_REVIEW"
|
||||
|
||||
|
||||
class ClaimRiskLevel(str, Enum):
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
|
||||
|
||||
class PublishingStatus(str, Enum):
|
||||
PUBLISH_NOT_STARTED = "PUBLISH_NOT_STARTED"
|
||||
PUBLISH_DRY_RUN_REQUIRED = "PUBLISH_DRY_RUN_REQUIRED"
|
||||
PUBLISH_DRY_RUN_RUNNING = "PUBLISH_DRY_RUN_RUNNING"
|
||||
PUBLISH_DRY_RUN_FAILED = "PUBLISH_DRY_RUN_FAILED"
|
||||
PUBLISH_COMMIT_READY = "PUBLISH_COMMIT_READY"
|
||||
PUBLISH_COMMIT_CREATED = "PUBLISH_COMMIT_CREATED"
|
||||
PUBLISH_VERIFICATION_FAILED = "PUBLISH_VERIFICATION_FAILED"
|
||||
|
||||
|
||||
class AssetStatus(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
GENERATED = "GENERATED"
|
||||
APPROVED = "APPROVED"
|
||||
REJECTED = "REJECTED"
|
||||
|
||||
|
||||
class AssetType(str, Enum):
|
||||
HERO_IMAGE = "hero_image"
|
||||
DIAGRAM = "diagram"
|
||||
TABLE = "table"
|
||||
INLINE_IMAGE = "inline_image"
|
||||
|
||||
|
||||
class PlanReviewStatus(str, Enum):
|
||||
PENDING_REVIEW = "PENDING_REVIEW"
|
||||
APPROVED = "APPROVED"
|
||||
REVISION_REQUESTED = "REVISION_REQUESTED"
|
||||
|
||||
|
||||
class ReviewStatus(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
APPROVED = "APPROVED"
|
||||
CHANGES_REQUESTED = "CHANGES_REQUESTED"
|
||||
|
||||
|
||||
class ReviewType(str, Enum):
|
||||
PLAN = "PLAN"
|
||||
EVIDENCE = "EVIDENCE"
|
||||
ASSET = "ASSET"
|
||||
SEO_LANGUAGE = "SEO_LANGUAGE"
|
||||
FINAL = "FINAL"
|
||||
|
||||
|
||||
class ScriptConfigVersionStatus(str, Enum):
|
||||
DRAFT = "DRAFT"
|
||||
ACTIVE = "ACTIVE"
|
||||
DEPRECATED = "DEPRECATED"
|
||||
@@ -0,0 +1,451 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .enums import (
|
||||
AgentJobErrorCategory,
|
||||
AgentJobStatus,
|
||||
AgentJobType,
|
||||
ArticleWorkflowStatus,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
ClaimRiskLevel,
|
||||
ClaimSupportStatus,
|
||||
PlanReviewStatus,
|
||||
PublishingStatus,
|
||||
ReviewStatus,
|
||||
ReviewType,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
)
|
||||
|
||||
|
||||
JsonObject = dict[str, Any]
|
||||
|
||||
|
||||
class ContractModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class UserSummary(ContractModel):
|
||||
id: UUID
|
||||
display_name: str = Field(min_length=1)
|
||||
role: Role
|
||||
|
||||
|
||||
class CurrentUser(ContractModel):
|
||||
id: UUID
|
||||
email: str = Field(min_length=1)
|
||||
display_name: str = Field(min_length=1)
|
||||
role: Role
|
||||
|
||||
|
||||
class CurrentUserResponse(ContractModel):
|
||||
user: CurrentUser
|
||||
|
||||
|
||||
class PublishingRules(ContractModel):
|
||||
repository_url: str = Field(min_length=1)
|
||||
production_branch: str = Field(min_length=1)
|
||||
content_format: str = Field(default="mdx", min_length=1)
|
||||
content_path_template: str = Field(min_length=1)
|
||||
asset_path_template: str = Field(min_length=1)
|
||||
frontmatter_mapping: JsonObject = Field(default_factory=dict)
|
||||
transform_script_version_id: UUID | None = None
|
||||
dry_run_renderer: str | None = None
|
||||
|
||||
|
||||
class TargetSiteConfig(ContractModel):
|
||||
id: UUID
|
||||
name: str = Field(min_length=1)
|
||||
slug: str = Field(min_length=1)
|
||||
publishing_type: str = Field(default="git_next", min_length=1)
|
||||
default_language: str = Field(default="en", min_length=2)
|
||||
brand_voice: str = Field(min_length=1)
|
||||
audience: str = Field(min_length=1)
|
||||
seo_rules: JsonObject = Field(default_factory=dict)
|
||||
visual_rules: JsonObject = Field(default_factory=dict)
|
||||
source_rules: JsonObject = Field(default_factory=dict)
|
||||
publishing_rules: PublishingRules
|
||||
active_script_config_version_id: UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TargetSiteConfigCreateRequest(ContractModel):
|
||||
name: str = Field(min_length=1)
|
||||
slug: str = Field(min_length=1)
|
||||
publishing_type: str = Field(default="git_next", min_length=1)
|
||||
default_language: str = Field(default="en", min_length=2)
|
||||
brand_voice: str = Field(min_length=1)
|
||||
audience: str = Field(min_length=1)
|
||||
seo_rules: JsonObject = Field(default_factory=dict)
|
||||
visual_rules: JsonObject = Field(default_factory=dict)
|
||||
source_rules: JsonObject = Field(default_factory=dict)
|
||||
publishing_rules: PublishingRules
|
||||
active_script_config_version_id: UUID | None = None
|
||||
|
||||
|
||||
class TargetSiteConfigUpdateRequest(ContractModel):
|
||||
name: str | None = Field(default=None, min_length=1)
|
||||
slug: str | None = Field(default=None, min_length=1)
|
||||
publishing_type: str | None = Field(default=None, min_length=1)
|
||||
default_language: str | None = Field(default=None, min_length=2)
|
||||
brand_voice: str | None = Field(default=None, min_length=1)
|
||||
audience: str | None = Field(default=None, min_length=1)
|
||||
seo_rules: JsonObject | None = None
|
||||
visual_rules: JsonObject | None = None
|
||||
source_rules: JsonObject | None = None
|
||||
publishing_rules: PublishingRules | None = None
|
||||
active_script_config_version_id: UUID | None = None
|
||||
|
||||
|
||||
class TargetSiteConfigResponse(ContractModel):
|
||||
site: TargetSiteConfig
|
||||
|
||||
|
||||
class ScriptConfigVersionSummary(ContractModel):
|
||||
id: UUID
|
||||
target_site_id: UUID
|
||||
version: int = Field(ge=1)
|
||||
status: ScriptConfigVersionStatus
|
||||
diff: JsonObject = Field(default_factory=dict)
|
||||
publishing_yaml_hash: str = Field(min_length=1)
|
||||
transform_script_hash: str = Field(min_length=1)
|
||||
rollback_target_version_id: UUID | None = None
|
||||
created_by: UUID
|
||||
created_at: datetime
|
||||
activated_at: datetime | None = None
|
||||
|
||||
|
||||
class ScriptConfigVersionCreateRequest(ContractModel):
|
||||
publishing_yaml: str = Field(min_length=1)
|
||||
transform_script: str = Field(min_length=1)
|
||||
diff: JsonObject = Field(default_factory=dict)
|
||||
rollback_target_version_id: UUID | None = None
|
||||
activate: bool = False
|
||||
version: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class ScriptConfigVersionResponse(ContractModel):
|
||||
version: ScriptConfigVersionSummary
|
||||
|
||||
|
||||
class ScriptConfigVersionListResponse(ContractModel):
|
||||
versions: list[ScriptConfigVersionSummary]
|
||||
|
||||
|
||||
class ArticleCreateRequest(ContractModel):
|
||||
target_site_id: UUID
|
||||
brief_description: str = Field(min_length=1)
|
||||
working_title: str | None = None
|
||||
language: str = Field(default="en", min_length=2)
|
||||
content_type: str = Field(default="longform_guide", min_length=1)
|
||||
primary_keyword: str | None = None
|
||||
assigned_editor_id: UUID | None = None
|
||||
|
||||
|
||||
class ArticleSummary(ContractModel):
|
||||
id: UUID
|
||||
target_site_id: UUID
|
||||
status: ArticleWorkflowStatus
|
||||
publishing_status: PublishingStatus
|
||||
brief_description: str
|
||||
working_title: str | None = None
|
||||
language: str
|
||||
content_type: str
|
||||
primary_keyword: str | None = None
|
||||
assigned_editor_id: UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ArticleCreateResponse(ContractModel):
|
||||
article: ArticleSummary
|
||||
|
||||
|
||||
class ArticleListResponse(ContractModel):
|
||||
articles: list[ArticleSummary]
|
||||
|
||||
|
||||
class WorkflowEventSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
event_type: str = Field(min_length=1)
|
||||
from_status: ArticleWorkflowStatus | None = None
|
||||
to_status: ArticleWorkflowStatus | None = None
|
||||
actor_user_id: UUID | None = None
|
||||
payload: JsonObject = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class BoundaryQuestionSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
sort_order: int = Field(ge=1)
|
||||
category: str = Field(min_length=1)
|
||||
question: str = Field(min_length=1)
|
||||
answer: str | None = None
|
||||
is_required: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class BoundaryQuestionUpdateRequest(ContractModel):
|
||||
answer: str | None = None
|
||||
|
||||
|
||||
class BoundaryQuestionResponse(ContractModel):
|
||||
question: BoundaryQuestionSummary
|
||||
|
||||
|
||||
class BoundaryQuestionListResponse(ContractModel):
|
||||
questions: list[BoundaryQuestionSummary]
|
||||
job: AgentJobSummary | None = None
|
||||
|
||||
|
||||
class PlanSectionSummary(ContractModel):
|
||||
id: UUID
|
||||
article_plan_id: UUID
|
||||
sort_order: int = Field(ge=1)
|
||||
heading: str = Field(min_length=1)
|
||||
purpose: str | None = None
|
||||
key_points: list[str] = Field(default_factory=list)
|
||||
evidence_needs: list[str] = Field(default_factory=list)
|
||||
claims_to_support: list[str] = Field(default_factory=list)
|
||||
target_word_count: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class PlanSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
version: int = Field(ge=1)
|
||||
status: PlanReviewStatus
|
||||
title_options: list[str] = Field(default_factory=list)
|
||||
recommended_title: str | None = None
|
||||
reader_persona: str | None = None
|
||||
search_intent: str | None = None
|
||||
thesis: str | None = None
|
||||
sections: list[PlanSectionSummary] = Field(default_factory=list)
|
||||
claims_to_prove: list[str] = Field(default_factory=list)
|
||||
evidence_needs: list[str] = Field(default_factory=list)
|
||||
visual_needs: list[str] = Field(default_factory=list)
|
||||
seo_notes: list[str] = Field(default_factory=list)
|
||||
source_requirements: list[str] = Field(default_factory=list)
|
||||
excluded_sources: list[str] = Field(default_factory=list)
|
||||
tone: str | None = None
|
||||
audience: str | None = None
|
||||
risks: list[str] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PlanSectionEditRequest(ContractModel):
|
||||
heading: str = Field(min_length=1)
|
||||
purpose: str | None = None
|
||||
key_points: list[str] = Field(default_factory=list)
|
||||
evidence_needs: list[str] = Field(default_factory=list)
|
||||
claims_to_support: list[str] = Field(default_factory=list)
|
||||
target_word_count: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class PlanUpdateRequest(ContractModel):
|
||||
title_options: list[str] | None = None
|
||||
recommended_title: str | None = None
|
||||
reader_persona: str | None = None
|
||||
search_intent: str | None = None
|
||||
thesis: str | None = None
|
||||
sections: list[PlanSectionEditRequest] | None = None
|
||||
claims_to_prove: list[str] | None = None
|
||||
evidence_needs: list[str] | None = None
|
||||
visual_needs: list[str] | None = None
|
||||
seo_notes: list[str] | None = None
|
||||
source_requirements: list[str] | None = None
|
||||
excluded_sources: list[str] | None = None
|
||||
tone: str | None = None
|
||||
audience: str | None = None
|
||||
risks: list[str] | None = None
|
||||
|
||||
|
||||
class PlanRevisionRequest(ContractModel):
|
||||
notes: str = Field(min_length=1)
|
||||
|
||||
|
||||
class PlanResponse(ContractModel):
|
||||
plan: PlanSummary
|
||||
|
||||
|
||||
class PlanListResponse(ContractModel):
|
||||
plans: list[PlanSummary]
|
||||
|
||||
|
||||
class ResearchArtifactSummary(ContractModel):
|
||||
artifact_type: str = Field(min_length=1)
|
||||
source_url: str = Field(min_length=1)
|
||||
object_key: str = Field(min_length=1)
|
||||
content_hash: str = Field(min_length=1)
|
||||
metadata: JsonObject = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ResearchArtifactManifestSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
agent_job_id: UUID
|
||||
s3_prefix: str = Field(min_length=1)
|
||||
artifacts: list[ResearchArtifactSummary] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ResearchStartResponse(ContractModel):
|
||||
article: ArticleSummary
|
||||
job: AgentJobSummary
|
||||
manifest: ResearchArtifactManifestSummary
|
||||
|
||||
|
||||
class ResearchListResponse(ContractModel):
|
||||
manifests: list[ResearchArtifactManifestSummary] = Field(default_factory=list)
|
||||
evidence: list["EvidenceSummary"] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EvidenceSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
source_title: str = Field(min_length=1)
|
||||
source_url: str = Field(min_length=1)
|
||||
source_type: str = Field(min_length=1)
|
||||
source_quality_score: float = Field(ge=0, le=1)
|
||||
summary: str = Field(min_length=1)
|
||||
supports_claims: list[UUID] = Field(default_factory=list)
|
||||
artifact_manifest_id: UUID
|
||||
retrieved_at: datetime
|
||||
|
||||
|
||||
class ClaimSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
section_id: UUID | None = None
|
||||
claim_text: str = Field(min_length=1)
|
||||
support_status: ClaimSupportStatus
|
||||
risk_level: ClaimRiskLevel
|
||||
evidence_item_ids: list[UUID] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DraftSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
version: int = Field(ge=1)
|
||||
title: str = Field(min_length=1)
|
||||
slug: str = Field(min_length=1)
|
||||
meta_title: str | None = None
|
||||
meta_description: str | None = None
|
||||
body_object_key: str | None = None
|
||||
status: ArticleWorkflowStatus
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AssetSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
asset_type: AssetType
|
||||
title: str = Field(min_length=1)
|
||||
prompt: str | None = None
|
||||
file_url: str | None = None
|
||||
alt_text: str | None = None
|
||||
caption: str | None = None
|
||||
status: AssetStatus
|
||||
|
||||
|
||||
class ReviewSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
review_type: ReviewType
|
||||
status: ReviewStatus
|
||||
reviewer_id: UUID | None = None
|
||||
notes: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ReviewActionResponse(ContractModel):
|
||||
review: ReviewSummary
|
||||
|
||||
|
||||
class PublishCommitSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
target_site_id: UUID
|
||||
repository_url: str = Field(min_length=1)
|
||||
branch: str = Field(min_length=1)
|
||||
commit_sha: str | None = None
|
||||
content_bundle_manifest: JsonObject = Field(default_factory=dict)
|
||||
status: PublishingStatus
|
||||
deployment_status: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RunnerFileRef(ContractModel):
|
||||
path: str = Field(min_length=1)
|
||||
content_hash: str | None = None
|
||||
|
||||
|
||||
class AgentJobSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID | None = None
|
||||
parent_job_id: UUID | None = None
|
||||
attempt: int = Field(default=1, ge=1)
|
||||
job_type: AgentJobType
|
||||
agent_profile: str = Field(min_length=1)
|
||||
status: AgentJobStatus
|
||||
workspace_path: str | None = None
|
||||
input_files: list[RunnerFileRef] = Field(default_factory=list)
|
||||
output_files: list[RunnerFileRef] = Field(default_factory=list)
|
||||
error_category: AgentJobErrorCategory | None = None
|
||||
error_message: str | None = None
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
exit_code: int | None = None
|
||||
duration_ms: int | None = Field(default=None, ge=0)
|
||||
queued_at: datetime
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
|
||||
|
||||
class AgentJobTestCodexRequest(ContractModel):
|
||||
fake_result: str = Field(default="valid", pattern="^(valid|invalid_schema)$")
|
||||
|
||||
|
||||
class AgentJobResponse(ContractModel):
|
||||
job: AgentJobSummary
|
||||
|
||||
|
||||
class AgentJobListResponse(ContractModel):
|
||||
jobs: list[AgentJobSummary]
|
||||
|
||||
|
||||
class AgentJobOutput(ContractModel):
|
||||
status: AgentJobStatus
|
||||
output_files: list[RunnerFileRef] = Field(default_factory=list)
|
||||
error_category: AgentJobErrorCategory | None = None
|
||||
error_message: str | None = None
|
||||
artifacts: list[ResearchArtifactSummary] = Field(default_factory=list)
|
||||
payload: JsonObject = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ArticleDetailResponse(ContractModel):
|
||||
article: ArticleSummary
|
||||
target_site: TargetSiteConfig | None = None
|
||||
workflow_events: list[WorkflowEventSummary] = Field(default_factory=list)
|
||||
boundary_questions: list[BoundaryQuestionSummary] = Field(default_factory=list)
|
||||
plan: PlanSummary | None = None
|
||||
draft: DraftSummary | None = None
|
||||
evidence: list[EvidenceSummary] = Field(default_factory=list)
|
||||
claims: list[ClaimSummary] = Field(default_factory=list)
|
||||
assets: list[AssetSummary] = Field(default_factory=list)
|
||||
reviews: list[ReviewSummary] = Field(default_factory=list)
|
||||
agent_jobs: list[AgentJobSummary] = Field(default_factory=list)
|
||||
research_manifests: list[ResearchArtifactManifestSummary] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
publish_commit: PublishCommitSummary | None = None
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
|
||||
from .enums import (
|
||||
AgentJobErrorCategory,
|
||||
AgentJobStatus,
|
||||
AgentJobType,
|
||||
ArticleWorkflowStatus,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
ClaimRiskLevel,
|
||||
ClaimSupportStatus,
|
||||
PlanReviewStatus,
|
||||
PublishingStatus,
|
||||
ReviewStatus,
|
||||
ReviewType,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
)
|
||||
from .models import (
|
||||
AgentJobOutput,
|
||||
AgentJobListResponse,
|
||||
AgentJobResponse,
|
||||
AgentJobSummary,
|
||||
AgentJobTestCodexRequest,
|
||||
ArticleCreateRequest,
|
||||
ArticleCreateResponse,
|
||||
ArticleDetailResponse,
|
||||
ArticleListResponse,
|
||||
ArticleSummary,
|
||||
BoundaryQuestionListResponse,
|
||||
BoundaryQuestionResponse,
|
||||
BoundaryQuestionSummary,
|
||||
BoundaryQuestionUpdateRequest,
|
||||
AssetSummary,
|
||||
ClaimSummary,
|
||||
CurrentUser,
|
||||
CurrentUserResponse,
|
||||
DraftSummary,
|
||||
EvidenceSummary,
|
||||
PlanSectionSummary,
|
||||
PlanSectionEditRequest,
|
||||
PlanListResponse,
|
||||
PlanResponse,
|
||||
PlanRevisionRequest,
|
||||
PlanSummary,
|
||||
PlanUpdateRequest,
|
||||
PublishCommitSummary,
|
||||
PublishingRules,
|
||||
ResearchArtifactManifestSummary,
|
||||
ResearchArtifactSummary,
|
||||
ResearchListResponse,
|
||||
ResearchStartResponse,
|
||||
ReviewActionResponse,
|
||||
ReviewSummary,
|
||||
RunnerFileRef,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionListResponse,
|
||||
ScriptConfigVersionResponse,
|
||||
ScriptConfigVersionSummary,
|
||||
TargetSiteConfig,
|
||||
TargetSiteConfigCreateRequest,
|
||||
TargetSiteConfigResponse,
|
||||
WorkflowEventSummary,
|
||||
TargetSiteConfigUpdateRequest,
|
||||
UserSummary,
|
||||
)
|
||||
|
||||
|
||||
CONTRACT_ENUMS: tuple[type[Enum], ...] = (
|
||||
Role,
|
||||
ArticleWorkflowStatus,
|
||||
AgentJobStatus,
|
||||
AgentJobType,
|
||||
AgentJobErrorCategory,
|
||||
ClaimSupportStatus,
|
||||
ClaimRiskLevel,
|
||||
PublishingStatus,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
PlanReviewStatus,
|
||||
ReviewStatus,
|
||||
ReviewType,
|
||||
ScriptConfigVersionStatus,
|
||||
)
|
||||
|
||||
CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
||||
UserSummary,
|
||||
CurrentUser,
|
||||
CurrentUserResponse,
|
||||
PublishingRules,
|
||||
TargetSiteConfig,
|
||||
TargetSiteConfigCreateRequest,
|
||||
TargetSiteConfigUpdateRequest,
|
||||
TargetSiteConfigResponse,
|
||||
ScriptConfigVersionSummary,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionResponse,
|
||||
ScriptConfigVersionListResponse,
|
||||
ArticleCreateRequest,
|
||||
ArticleSummary,
|
||||
ArticleCreateResponse,
|
||||
ArticleListResponse,
|
||||
WorkflowEventSummary,
|
||||
BoundaryQuestionSummary,
|
||||
BoundaryQuestionUpdateRequest,
|
||||
BoundaryQuestionResponse,
|
||||
BoundaryQuestionListResponse,
|
||||
PlanSectionSummary,
|
||||
PlanSectionEditRequest,
|
||||
PlanSummary,
|
||||
PlanUpdateRequest,
|
||||
PlanRevisionRequest,
|
||||
PlanResponse,
|
||||
PlanListResponse,
|
||||
ResearchArtifactSummary,
|
||||
ResearchArtifactManifestSummary,
|
||||
ResearchStartResponse,
|
||||
ResearchListResponse,
|
||||
EvidenceSummary,
|
||||
ClaimSummary,
|
||||
DraftSummary,
|
||||
AssetSummary,
|
||||
ReviewSummary,
|
||||
ReviewActionResponse,
|
||||
PublishCommitSummary,
|
||||
RunnerFileRef,
|
||||
AgentJobSummary,
|
||||
AgentJobTestCodexRequest,
|
||||
AgentJobResponse,
|
||||
AgentJobListResponse,
|
||||
AgentJobOutput,
|
||||
ArticleDetailResponse,
|
||||
)
|
||||
|
||||
|
||||
def inject_contract_schemas(openapi_schema: dict[str, Any]) -> None:
|
||||
schemas = openapi_schema.setdefault("components", {}).setdefault("schemas", {})
|
||||
|
||||
for enum in CONTRACT_ENUMS:
|
||||
schemas[enum.__name__] = TypeAdapter(enum).json_schema(
|
||||
ref_template="#/components/schemas/{model}"
|
||||
)
|
||||
|
||||
for model in CONTRACT_SCHEMA_MODELS:
|
||||
schema = model.model_json_schema(ref_template="#/components/schemas/{model}")
|
||||
for name, definition in schema.pop("$defs", {}).items():
|
||||
schemas.setdefault(name, definition)
|
||||
schemas[model.__name__] = schema
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .enums import ArticleWorkflowStatus
|
||||
|
||||
|
||||
ARTICLE_WORKFLOW_STATUSES: tuple[ArticleWorkflowStatus, ...] = (
|
||||
ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
|
||||
ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED,
|
||||
ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
|
||||
ArticleWorkflowStatus.PLAN_GENERATED,
|
||||
ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED,
|
||||
ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
|
||||
ArticleWorkflowStatus.RESEARCH_RUNNING,
|
||||
ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
|
||||
ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING,
|
||||
ArticleWorkflowStatus.DRAFT_ASSEMBLED,
|
||||
ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
|
||||
ArticleWorkflowStatus.FINAL_REVIEW_REQUIRED,
|
||||
ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
|
||||
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
|
||||
ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
|
||||
ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED,
|
||||
)
|
||||
|
||||
|
||||
ARTICLE_WORKFLOW_TRANSITIONS: dict[
|
||||
ArticleWorkflowStatus, tuple[ArticleWorkflowStatus, ...]
|
||||
] = {
|
||||
ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED: (
|
||||
ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED,
|
||||
),
|
||||
ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED: (
|
||||
ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
|
||||
),
|
||||
ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED: (
|
||||
ArticleWorkflowStatus.PLAN_GENERATED,
|
||||
),
|
||||
ArticleWorkflowStatus.PLAN_GENERATED: (
|
||||
ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED,
|
||||
),
|
||||
ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED: (
|
||||
ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
|
||||
ArticleWorkflowStatus.RESEARCH_RUNNING,
|
||||
),
|
||||
ArticleWorkflowStatus.PLAN_REVISION_REQUIRED: (
|
||||
ArticleWorkflowStatus.PLAN_GENERATED,
|
||||
),
|
||||
ArticleWorkflowStatus.RESEARCH_RUNNING: (
|
||||
ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
|
||||
),
|
||||
ArticleWorkflowStatus.EVIDENCE_MATRIX_READY: (
|
||||
ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING,
|
||||
),
|
||||
ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING: (
|
||||
ArticleWorkflowStatus.DRAFT_ASSEMBLED,
|
||||
),
|
||||
ArticleWorkflowStatus.DRAFT_ASSEMBLED: (
|
||||
ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
|
||||
),
|
||||
ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY: (
|
||||
ArticleWorkflowStatus.FINAL_REVIEW_REQUIRED,
|
||||
),
|
||||
ArticleWorkflowStatus.FINAL_REVIEW_REQUIRED: (
|
||||
ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
|
||||
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
|
||||
),
|
||||
ArticleWorkflowStatus.FINAL_REVISION_REQUIRED: (
|
||||
ArticleWorkflowStatus.DRAFT_ASSEMBLED,
|
||||
),
|
||||
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED: (
|
||||
ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
|
||||
),
|
||||
ArticleWorkflowStatus.PUBLISH_COMMIT_READY: (
|
||||
ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED,
|
||||
),
|
||||
ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED: (),
|
||||
}
|
||||
|
||||
|
||||
def next_workflow_statuses(
|
||||
status: ArticleWorkflowStatus,
|
||||
) -> tuple[ArticleWorkflowStatus, ...]:
|
||||
return ARTICLE_WORKFLOW_TRANSITIONS[status]
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
USERS_TABLE = "users"
|
||||
TARGET_SITES_TABLE = "target_sites"
|
||||
SCRIPT_CONFIG_VERSIONS_TABLE = "script_config_versions"
|
||||
ARTICLES_TABLE = "articles"
|
||||
BOUNDARY_QUESTIONS_TABLE = "boundary_questions"
|
||||
ARTICLE_PLANS_TABLE = "article_plans"
|
||||
PLAN_SECTIONS_TABLE = "plan_sections"
|
||||
EVIDENCE_ITEMS_TABLE = "evidence_items"
|
||||
CLAIMS_TABLE = "claims"
|
||||
ARTICLE_DRAFTS_TABLE = "article_drafts"
|
||||
ASSETS_TABLE = "assets"
|
||||
WORKFLOW_EVENTS_TABLE = "workflow_events"
|
||||
AGENT_JOBS_TABLE = "agent_jobs"
|
||||
RESEARCH_RUN_MANIFESTS_TABLE = "research_run_manifests"
|
||||
PUBLISH_COMMITS_TABLE = "publish_commits"
|
||||
PROMPT_VERSIONS_TABLE = "prompt_versions"
|
||||
|
||||
|
||||
CORE_TABLES: tuple[str, ...] = (
|
||||
USERS_TABLE,
|
||||
TARGET_SITES_TABLE,
|
||||
SCRIPT_CONFIG_VERSIONS_TABLE,
|
||||
ARTICLES_TABLE,
|
||||
BOUNDARY_QUESTIONS_TABLE,
|
||||
ARTICLE_PLANS_TABLE,
|
||||
PLAN_SECTIONS_TABLE,
|
||||
EVIDENCE_ITEMS_TABLE,
|
||||
CLAIMS_TABLE,
|
||||
ARTICLE_DRAFTS_TABLE,
|
||||
ASSETS_TABLE,
|
||||
WORKFLOW_EVENTS_TABLE,
|
||||
AGENT_JOBS_TABLE,
|
||||
RESEARCH_RUN_MANIFESTS_TABLE,
|
||||
PUBLISH_COMMITS_TABLE,
|
||||
PROMPT_VERSIONS_TABLE,
|
||||
)
|
||||
|
||||
|
||||
SEEDED_ADMIN_EMAIL = "admin@example.com"
|
||||
SEEDED_EDITOR_EMAIL = "editor@example.com"
|
||||
SEEDED_TARGET_SITE_SLUG = "b2b_saas_blog"
|
||||
@@ -2,3 +2,17 @@
|
||||
|
||||
Database, cache, object storage, queue, and other external adapters belong in
|
||||
this layer.
|
||||
|
||||
## Database
|
||||
|
||||
Run migrations from the repository root:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://pipeline:pipeline_local@localhost:5432/pipeline alembic upgrade head
|
||||
```
|
||||
|
||||
`POSTGRES_DSN` is also supported. Reference data can be seeded with:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://pipeline:pipeline_local@localhost:5432/pipeline make seed
|
||||
```
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ObjectStorageClient:
|
||||
def put_text(self, *, object_key: str, content: str) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class LocalObjectStorageClient(ObjectStorageClient):
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.root = root
|
||||
|
||||
def put_text(self, *, object_key: str, content: str) -> str:
|
||||
path = self.root / object_key
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return f"file://{path}"
|
||||
|
||||
|
||||
class S3ObjectStorageClient(ObjectStorageClient):
|
||||
def __init__(self) -> None:
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
self.bucket = _required_env("OBJECT_STORAGE_BUCKET")
|
||||
self.client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=_required_env("OBJECT_STORAGE_ENDPOINT"),
|
||||
aws_access_key_id=_required_env("OBJECT_STORAGE_ACCESS_KEY_ID"),
|
||||
aws_secret_access_key=_required_env("OBJECT_STORAGE_SECRET_ACCESS_KEY"),
|
||||
region_name=os.environ.get("OBJECT_STORAGE_REGION", "us-east-1"),
|
||||
config=Config(signature_version="s3v4"),
|
||||
)
|
||||
|
||||
def put_text(self, *, object_key: str, content: str) -> str:
|
||||
self.client.put_object(
|
||||
Bucket=self.bucket,
|
||||
Key=object_key,
|
||||
Body=content.encode("utf-8"),
|
||||
ContentType="application/json; charset=utf-8",
|
||||
)
|
||||
return f"s3://{self.bucket}/{object_key}"
|
||||
|
||||
|
||||
def open_object_storage_client() -> ObjectStorageClient:
|
||||
local_root = os.environ.get("OBJECT_STORAGE_LOCAL_ROOT")
|
||||
if local_root:
|
||||
return LocalObjectStorageClient(Path(local_root))
|
||||
|
||||
if os.environ.get("OBJECT_STORAGE_ENDPOINT"):
|
||||
return S3ObjectStorageClient()
|
||||
|
||||
return LocalObjectStorageClient(Path("/tmp/pipeline-object-storage"))
|
||||
|
||||
|
||||
def _required_env(name: str) -> str:
|
||||
value = os.environ.get(name)
|
||||
if not value:
|
||||
raise RuntimeError(f"Missing required environment variable: {name}")
|
||||
return value
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,690 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from collections.abc import Iterable
|
||||
from contextlib import closing
|
||||
|
||||
from src.domain.contracts import (
|
||||
AgentJobErrorCategory,
|
||||
AgentJobStatus,
|
||||
AgentJobType,
|
||||
ArticleWorkflowStatus,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
ClaimRiskLevel,
|
||||
ClaimSupportStatus,
|
||||
PlanReviewStatus,
|
||||
PublishingStatus,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
)
|
||||
|
||||
|
||||
def _quoted_values(values: Iterable[str]) -> str:
|
||||
return ", ".join(f"'{value}'" for value in values)
|
||||
|
||||
|
||||
ROLE_VALUES = _quoted_values(role.value for role in Role)
|
||||
ARTICLE_STATUS_VALUES = _quoted_values(status.value for status in ArticleWorkflowStatus)
|
||||
PUBLISHING_STATUS_VALUES = _quoted_values(status.value for status in PublishingStatus)
|
||||
SCRIPT_CONFIG_STATUS_VALUES = _quoted_values(
|
||||
status.value for status in ScriptConfigVersionStatus
|
||||
)
|
||||
PLAN_REVIEW_STATUS_VALUES = _quoted_values(status.value for status in PlanReviewStatus)
|
||||
CLAIM_SUPPORT_STATUS_VALUES = _quoted_values(
|
||||
status.value for status in ClaimSupportStatus
|
||||
)
|
||||
CLAIM_RISK_LEVEL_VALUES = _quoted_values(level.value for level in ClaimRiskLevel)
|
||||
ASSET_TYPE_VALUES = _quoted_values(asset_type.value for asset_type in AssetType)
|
||||
ASSET_STATUS_VALUES = _quoted_values(status.value for status in AssetStatus)
|
||||
AGENT_JOB_TYPE_VALUES = _quoted_values(job_type.value for job_type in AgentJobType)
|
||||
AGENT_JOB_STATUS_VALUES = _quoted_values(status.value for status in AgentJobStatus)
|
||||
AGENT_JOB_ERROR_CATEGORY_VALUES = _quoted_values(
|
||||
category.value for category in AgentJobErrorCategory
|
||||
)
|
||||
|
||||
|
||||
POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
||||
'CREATE EXTENSION IF NOT EXISTS "pgcrypto"',
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ({ROLE_VALUES})),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS target_sites (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
publishing_type TEXT NOT NULL DEFAULT 'git_next',
|
||||
default_language TEXT NOT NULL DEFAULT 'en',
|
||||
brand_voice TEXT NOT NULL,
|
||||
audience TEXT NOT NULL,
|
||||
seo_rules JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
visual_rules JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
source_rules JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
publishing_rules JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
active_script_config_version_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS script_config_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
target_site_id UUID NOT NULL REFERENCES target_sites(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ({SCRIPT_CONFIG_STATUS_VALUES})),
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
diff JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
||||
rollback_target_version_id UUID REFERENCES script_config_versions(id),
|
||||
activated_at TIMESTAMPTZ,
|
||||
publishing_yaml TEXT NOT NULL,
|
||||
publishing_yaml_hash TEXT NOT NULL,
|
||||
transform_script TEXT NOT NULL,
|
||||
transform_script_hash TEXT NOT NULL,
|
||||
UNIQUE (target_site_id, version)
|
||||
)
|
||||
""",
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
target_site_id UUID NOT NULL REFERENCES target_sites(id),
|
||||
status TEXT NOT NULL CHECK (status IN ({ARTICLE_STATUS_VALUES})),
|
||||
publishing_status TEXT NOT NULL CHECK (
|
||||
publishing_status IN ({PUBLISHING_STATUS_VALUES})
|
||||
),
|
||||
brief_description TEXT NOT NULL,
|
||||
working_title TEXT,
|
||||
language TEXT NOT NULL DEFAULT 'en',
|
||||
content_type TEXT NOT NULL DEFAULT 'longform_guide',
|
||||
primary_keyword TEXT,
|
||||
assigned_editor_id UUID REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS boundary_questions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'general',
|
||||
question TEXT NOT NULL,
|
||||
answer TEXT,
|
||||
is_required BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (article_id, sort_order)
|
||||
)
|
||||
""",
|
||||
"ALTER TABLE boundary_questions ADD COLUMN IF NOT EXISTS category TEXT NOT NULL DEFAULT 'general'",
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS article_plans (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ({PLAN_REVIEW_STATUS_VALUES})),
|
||||
title_options JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
recommended_title TEXT,
|
||||
reader_persona TEXT,
|
||||
search_intent TEXT,
|
||||
thesis TEXT,
|
||||
claims_to_prove JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
evidence_needs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
visual_needs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
seo_notes JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
source_requirements JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
excluded_sources JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
tone TEXT,
|
||||
audience TEXT,
|
||||
risks JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (article_id, version)
|
||||
)
|
||||
""",
|
||||
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS title_options JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS reader_persona TEXT",
|
||||
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS evidence_needs JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS visual_needs JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS seo_notes JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS source_requirements JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS excluded_sources JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS tone TEXT",
|
||||
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS audience TEXT",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS plan_sections (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
article_plan_id UUID NOT NULL REFERENCES article_plans(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL,
|
||||
heading TEXT NOT NULL,
|
||||
purpose TEXT,
|
||||
key_points JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
evidence_needs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
claims_to_support JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
target_word_count INTEGER,
|
||||
UNIQUE (article_plan_id, sort_order)
|
||||
)
|
||||
""",
|
||||
"ALTER TABLE plan_sections ADD COLUMN IF NOT EXISTS key_points JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||
"ALTER TABLE plan_sections ADD COLUMN IF NOT EXISTS evidence_needs JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS evidence_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||
source_title TEXT NOT NULL,
|
||||
source_url TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL,
|
||||
source_quality_score NUMERIC(4, 3) NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
supports_claims JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
artifact_manifest_id UUID,
|
||||
retrieved_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS claims (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||
section_id UUID REFERENCES plan_sections(id) ON DELETE SET NULL,
|
||||
claim_text TEXT NOT NULL,
|
||||
support_status TEXT NOT NULL CHECK (
|
||||
support_status IN ({CLAIM_SUPPORT_STATUS_VALUES})
|
||||
),
|
||||
risk_level TEXT NOT NULL CHECK (risk_level IN ({CLAIM_RISK_LEVEL_VALUES})),
|
||||
evidence_item_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS article_drafts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
meta_title TEXT,
|
||||
meta_description TEXT,
|
||||
body_object_key TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ({ARTICLE_STATUS_VALUES})),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (article_id, version)
|
||||
)
|
||||
""",
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||
asset_type TEXT NOT NULL CHECK (asset_type IN ({ASSET_TYPE_VALUES})),
|
||||
title TEXT NOT NULL,
|
||||
prompt TEXT,
|
||||
file_url TEXT,
|
||||
alt_text TEXT,
|
||||
caption TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ({ASSET_STATUS_VALUES})),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS workflow_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
from_status TEXT CHECK (from_status IN ({ARTICLE_STATUS_VALUES})),
|
||||
to_status TEXT CHECK (to_status IN ({ARTICLE_STATUS_VALUES})),
|
||||
actor_user_id UUID REFERENCES users(id),
|
||||
payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS agent_jobs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
article_id UUID REFERENCES articles(id) ON DELETE SET NULL,
|
||||
parent_job_id UUID REFERENCES agent_jobs(id) ON DELETE SET NULL,
|
||||
attempt INTEGER NOT NULL DEFAULT 1,
|
||||
job_type TEXT NOT NULL CHECK (job_type IN ({AGENT_JOB_TYPE_VALUES})),
|
||||
agent_profile TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ({AGENT_JOB_STATUS_VALUES})),
|
||||
workspace_path TEXT,
|
||||
input_files JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
output_files JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
error_category TEXT CHECK (
|
||||
error_category IS NULL
|
||||
OR error_category IN ({AGENT_JOB_ERROR_CATEGORY_VALUES})
|
||||
),
|
||||
error_message TEXT,
|
||||
stdout TEXT NOT NULL DEFAULT '',
|
||||
stderr TEXT NOT NULL DEFAULT '',
|
||||
exit_code INTEGER,
|
||||
duration_ms INTEGER,
|
||||
queued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ
|
||||
)
|
||||
""",
|
||||
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS parent_job_id UUID REFERENCES agent_jobs(id) ON DELETE SET NULL",
|
||||
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS attempt INTEGER NOT NULL DEFAULT 1",
|
||||
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS stdout TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS stderr TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS exit_code INTEGER",
|
||||
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS duration_ms INTEGER",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS research_run_manifests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||
agent_job_id UUID NOT NULL REFERENCES agent_jobs(id) ON DELETE CASCADE,
|
||||
s3_prefix TEXT NOT NULL,
|
||||
source_urls JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
object_keys JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
content_hashes JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
artifact_types JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
metadata_references JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
artifacts JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS publish_commits (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||
target_site_id UUID NOT NULL REFERENCES target_sites(id),
|
||||
repository_url TEXT NOT NULL,
|
||||
branch TEXT NOT NULL,
|
||||
commit_sha TEXT,
|
||||
content_bundle_manifest JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
||||
status TEXT NOT NULL CHECK (status IN ({PUBLISHING_STATUS_VALUES})),
|
||||
deployment_status TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS prompt_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
prompt_key TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ({SCRIPT_CONFIG_STATUS_VALUES})),
|
||||
body TEXT NOT NULL,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
activated_at TIMESTAMPTZ,
|
||||
diff JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
||||
UNIQUE (prompt_key, version)
|
||||
)
|
||||
""",
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS script_config_version_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
target_site_id UUID NOT NULL REFERENCES target_sites(id) ON DELETE CASCADE,
|
||||
version_id UUID NOT NULL REFERENCES script_config_versions(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
actor_user_id UUID REFERENCES users(id),
|
||||
payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_target_sites_slug ON target_sites (slug)",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_dashboard
|
||||
ON articles (target_site_id, status, publishing_status, updated_at DESC)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_editor_dashboard
|
||||
ON articles (assigned_editor_id, updated_at DESC)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_jobs_queue
|
||||
ON agent_jobs (status, queued_at)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_jobs_article_timeline
|
||||
ON agent_jobs (article_id, job_type, queued_at)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_events_timeline
|
||||
ON workflow_events (article_id, created_at)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_research_run_manifests_article
|
||||
ON research_run_manifests (article_id, created_at)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_publish_commits_article
|
||||
ON publish_commits (article_id, created_at)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_script_config_version_events_site
|
||||
ON script_config_version_events (target_site_id, created_at)
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS target_sites (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
publishing_type TEXT NOT NULL DEFAULT 'git_next',
|
||||
default_language TEXT NOT NULL DEFAULT 'en',
|
||||
brand_voice TEXT NOT NULL,
|
||||
audience TEXT NOT NULL,
|
||||
seo_rules TEXT NOT NULL DEFAULT '{}',
|
||||
visual_rules TEXT NOT NULL DEFAULT '{}',
|
||||
source_rules TEXT NOT NULL DEFAULT '{}',
|
||||
publishing_rules TEXT NOT NULL DEFAULT '{}',
|
||||
active_script_config_version_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS script_config_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
target_site_id TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
diff TEXT NOT NULL DEFAULT '{}',
|
||||
rollback_target_version_id TEXT,
|
||||
activated_at TEXT,
|
||||
publishing_yaml TEXT NOT NULL,
|
||||
publishing_yaml_hash TEXT NOT NULL,
|
||||
transform_script TEXT NOT NULL,
|
||||
transform_script_hash TEXT NOT NULL,
|
||||
UNIQUE (target_site_id, version)
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
id TEXT PRIMARY KEY,
|
||||
target_site_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
publishing_status TEXT NOT NULL,
|
||||
brief_description TEXT NOT NULL,
|
||||
working_title TEXT,
|
||||
language TEXT NOT NULL DEFAULT 'en',
|
||||
content_type TEXT NOT NULL DEFAULT 'longform_guide',
|
||||
primary_keyword TEXT,
|
||||
assigned_editor_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS boundary_questions (
|
||||
id TEXT PRIMARY KEY,
|
||||
article_id TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'general',
|
||||
question TEXT NOT NULL,
|
||||
answer TEXT,
|
||||
is_required INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (article_id, sort_order)
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS article_plans (
|
||||
id TEXT PRIMARY KEY,
|
||||
article_id TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
title_options TEXT NOT NULL DEFAULT '[]',
|
||||
recommended_title TEXT,
|
||||
reader_persona TEXT,
|
||||
search_intent TEXT,
|
||||
thesis TEXT,
|
||||
claims_to_prove TEXT NOT NULL DEFAULT '[]',
|
||||
evidence_needs TEXT NOT NULL DEFAULT '[]',
|
||||
visual_needs TEXT NOT NULL DEFAULT '[]',
|
||||
seo_notes TEXT NOT NULL DEFAULT '[]',
|
||||
source_requirements TEXT NOT NULL DEFAULT '[]',
|
||||
excluded_sources TEXT NOT NULL DEFAULT '[]',
|
||||
tone TEXT,
|
||||
audience TEXT,
|
||||
risks TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (article_id, version)
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS plan_sections (
|
||||
id TEXT PRIMARY KEY,
|
||||
article_plan_id TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL,
|
||||
heading TEXT NOT NULL,
|
||||
purpose TEXT,
|
||||
key_points TEXT NOT NULL DEFAULT '[]',
|
||||
evidence_needs TEXT NOT NULL DEFAULT '[]',
|
||||
claims_to_support TEXT NOT NULL DEFAULT '[]',
|
||||
target_word_count INTEGER,
|
||||
UNIQUE (article_plan_id, sort_order)
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS evidence_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
article_id TEXT NOT NULL,
|
||||
source_title TEXT NOT NULL,
|
||||
source_url TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL,
|
||||
source_quality_score REAL NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
supports_claims TEXT NOT NULL DEFAULT '[]',
|
||||
artifact_manifest_id TEXT,
|
||||
retrieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS claims (
|
||||
id TEXT PRIMARY KEY,
|
||||
article_id TEXT NOT NULL,
|
||||
section_id TEXT,
|
||||
claim_text TEXT NOT NULL,
|
||||
support_status TEXT NOT NULL,
|
||||
risk_level TEXT NOT NULL,
|
||||
evidence_item_ids TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS article_drafts (
|
||||
id TEXT PRIMARY KEY,
|
||||
article_id TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
meta_title TEXT,
|
||||
meta_description TEXT,
|
||||
body_object_key TEXT,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (article_id, version)
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
article_id TEXT NOT NULL,
|
||||
asset_type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
prompt TEXT,
|
||||
file_url TEXT,
|
||||
alt_text TEXT,
|
||||
caption TEXT,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS workflow_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
article_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
from_status TEXT,
|
||||
to_status TEXT,
|
||||
actor_user_id TEXT,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS agent_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
article_id TEXT,
|
||||
parent_job_id TEXT,
|
||||
attempt INTEGER NOT NULL DEFAULT 1,
|
||||
job_type TEXT NOT NULL,
|
||||
agent_profile TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
workspace_path TEXT,
|
||||
input_files TEXT NOT NULL DEFAULT '[]',
|
||||
output_files TEXT NOT NULL DEFAULT '[]',
|
||||
error_category TEXT,
|
||||
error_message TEXT,
|
||||
stdout TEXT NOT NULL DEFAULT '',
|
||||
stderr TEXT NOT NULL DEFAULT '',
|
||||
exit_code INTEGER,
|
||||
duration_ms INTEGER,
|
||||
queued_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at TEXT,
|
||||
finished_at TEXT
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS research_run_manifests (
|
||||
id TEXT PRIMARY KEY,
|
||||
article_id TEXT NOT NULL,
|
||||
agent_job_id TEXT NOT NULL,
|
||||
s3_prefix TEXT NOT NULL,
|
||||
source_urls TEXT NOT NULL DEFAULT '[]',
|
||||
object_keys TEXT NOT NULL DEFAULT '[]',
|
||||
content_hashes TEXT NOT NULL DEFAULT '[]',
|
||||
artifact_types TEXT NOT NULL DEFAULT '[]',
|
||||
metadata_references TEXT NOT NULL DEFAULT '[]',
|
||||
artifacts TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS publish_commits (
|
||||
id TEXT PRIMARY KEY,
|
||||
article_id TEXT NOT NULL,
|
||||
target_site_id TEXT NOT NULL,
|
||||
repository_url TEXT NOT NULL,
|
||||
branch TEXT NOT NULL,
|
||||
commit_sha TEXT,
|
||||
content_bundle_manifest TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL,
|
||||
deployment_status TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS prompt_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
prompt_key TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
activated_at TEXT,
|
||||
diff TEXT NOT NULL DEFAULT '{}',
|
||||
UNIQUE (prompt_key, version)
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS script_config_version_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
target_site_id TEXT NOT NULL,
|
||||
version_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
actor_user_id TEXT,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_target_sites_slug ON target_sites (slug)",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_dashboard
|
||||
ON articles (target_site_id, status, publishing_status, updated_at)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_editor_dashboard
|
||||
ON articles (assigned_editor_id, updated_at)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_jobs_queue
|
||||
ON agent_jobs (status, queued_at)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_jobs_article_timeline
|
||||
ON agent_jobs (article_id, job_type, queued_at)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_events_timeline
|
||||
ON workflow_events (article_id, created_at)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_research_run_manifests_article
|
||||
ON research_run_manifests (article_id, created_at)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_publish_commits_article
|
||||
ON publish_commits (article_id, created_at)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_script_config_version_events_site
|
||||
ON script_config_version_events (target_site_id, created_at)
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def schema_statements_for_dsn(dsn: str) -> tuple[str, ...]:
|
||||
if dsn.startswith("sqlite:///"):
|
||||
return SQLITE_SCHEMA_STATEMENTS
|
||||
return POSTGRES_SCHEMA_STATEMENTS
|
||||
|
||||
|
||||
def setup_database(dsn: str) -> None:
|
||||
statements = schema_statements_for_dsn(dsn)
|
||||
if dsn.startswith("sqlite:///"):
|
||||
sqlite_path = dsn.removeprefix("sqlite:///")
|
||||
with closing(sqlite3.connect(sqlite_path)) as connection:
|
||||
for statement in statements:
|
||||
connection.execute(statement)
|
||||
connection.commit()
|
||||
return
|
||||
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(dsn) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
for statement in statements:
|
||||
cursor.execute(statement)
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from src.application.seed_data import seed_reference_data
|
||||
from src.infrastructure.repositories import open_backend_repository
|
||||
|
||||
|
||||
def main() -> None:
|
||||
dsn = os.environ.get("DATABASE_URL") or os.environ.get("POSTGRES_DSN")
|
||||
if not dsn:
|
||||
raise RuntimeError("DATABASE_URL or POSTGRES_DSN must be set")
|
||||
|
||||
repository = open_backend_repository(dsn)
|
||||
repository.setup()
|
||||
seed_reference_data(repository)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Callable, Iterable
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
|
||||
from src.application.auth import get_current_user_by_email
|
||||
from src.application.seed_data import seed_reference_data
|
||||
from src.domain.contracts import CurrentUser, Role
|
||||
from src.infrastructure.repositories import BackendRepository, open_backend_repository
|
||||
|
||||
|
||||
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||
UNAUTHORIZED_DETAIL = "Unauthorized"
|
||||
FORBIDDEN_DETAIL = "Forbidden"
|
||||
|
||||
_runtime_repository: BackendRepository | None = None
|
||||
_runtime_repository_dsn: str | None = None
|
||||
_runtime_temp_dir: tempfile.TemporaryDirectory[str] | None = None
|
||||
|
||||
|
||||
def get_repository() -> BackendRepository:
|
||||
global _runtime_repository
|
||||
global _runtime_repository_dsn
|
||||
global _runtime_temp_dir
|
||||
|
||||
dsn = (
|
||||
os.environ.get("DATABASE_URL")
|
||||
or os.environ.get("POSTGRES_DSN")
|
||||
or os.environ.get("PIPELINE_TEST_DATABASE_DSN")
|
||||
)
|
||||
if dsn is None:
|
||||
if _runtime_temp_dir is None:
|
||||
_runtime_temp_dir = tempfile.TemporaryDirectory()
|
||||
sqlite_path = Path(_runtime_temp_dir.name) / "pipeline-demo-auth.db"
|
||||
dsn = f"sqlite:///{sqlite_path}"
|
||||
|
||||
if _runtime_repository is not None and _runtime_repository_dsn == dsn:
|
||||
return _runtime_repository
|
||||
|
||||
repository = open_backend_repository(dsn)
|
||||
repository.setup()
|
||||
seed_reference_data(repository)
|
||||
_runtime_repository = repository
|
||||
_runtime_repository_dsn = dsn
|
||||
return repository
|
||||
|
||||
|
||||
def get_current_user(
|
||||
demo_user_email: str | None = Header(default=None, alias=DEMO_USER_EMAIL_HEADER),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> CurrentUser:
|
||||
if not demo_user_email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=UNAUTHORIZED_DETAIL,
|
||||
)
|
||||
|
||||
try:
|
||||
return get_current_user_by_email(repository, demo_user_email)
|
||||
except LookupError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=UNAUTHORIZED_DETAIL,
|
||||
) from error
|
||||
|
||||
|
||||
def require_roles(
|
||||
allowed_roles: Iterable[Role],
|
||||
) -> Callable[[CurrentUser], CurrentUser]:
|
||||
allowed = frozenset(allowed_roles)
|
||||
|
||||
def dependency(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CurrentUser:
|
||||
if current_user.role not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=FORBIDDEN_DETAIL,
|
||||
)
|
||||
return current_user
|
||||
|
||||
return dependency
|
||||
@@ -1,10 +1,29 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from src.infrastructure.dependencies import check_dependencies
|
||||
from src.domain.contracts.openapi import inject_contract_schemas
|
||||
from src.presentation.routes.agent_jobs import (
|
||||
internal_router as internal_agent_jobs_router,
|
||||
)
|
||||
from src.presentation.routes.agent_jobs import router as agent_jobs_router
|
||||
from src.presentation.routes.articles import router as articles_router
|
||||
from src.presentation.routes.auth import router as auth_router
|
||||
from src.presentation.routes.boundary_questions import router as boundary_questions_router
|
||||
from src.presentation.routes.plans import router as plans_router
|
||||
from src.presentation.routes.sites import router as sites_router
|
||||
|
||||
|
||||
app = FastAPI(title="AI Content Pipeline Backend")
|
||||
app.include_router(auth_router)
|
||||
app.include_router(articles_router)
|
||||
app.include_router(boundary_questions_router)
|
||||
app.include_router(plans_router)
|
||||
app.include_router(agent_jobs_router)
|
||||
app.include_router(internal_agent_jobs_router)
|
||||
app.include_router(sites_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -14,6 +33,8 @@ def health() -> dict[str, str]:
|
||||
|
||||
@app.get("/health/dependencies")
|
||||
def dependency_health() -> JSONResponse:
|
||||
from src.infrastructure.dependencies import check_dependencies
|
||||
|
||||
dependencies = check_dependencies()
|
||||
is_ok = all(status == "ok" for status in dependencies.values())
|
||||
|
||||
@@ -25,3 +46,20 @@ def dependency_health() -> JSONResponse:
|
||||
"dependencies": dependencies,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def custom_openapi() -> dict[str, Any]:
|
||||
if app.openapi_schema:
|
||||
return app.openapi_schema
|
||||
|
||||
openapi_schema = get_openapi(
|
||||
title=app.title,
|
||||
version=app.version,
|
||||
routes=app.routes,
|
||||
)
|
||||
inject_contract_schemas(openapi_schema)
|
||||
app.openapi_schema = openapi_schema
|
||||
return app.openapi_schema
|
||||
|
||||
|
||||
app.openapi = custom_openapi
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Backend HTTP route modules."""
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.application.agent_jobs import (
|
||||
cancel_agent_job,
|
||||
claim_next_agent_job,
|
||||
complete_agent_job,
|
||||
create_test_codex_job,
|
||||
get_agent_job,
|
||||
list_agent_jobs,
|
||||
retry_agent_job,
|
||||
)
|
||||
from src.domain.auth import ADMIN_ROLES
|
||||
from src.domain.contracts import (
|
||||
AgentJobListResponse,
|
||||
AgentJobResponse,
|
||||
AgentJobTestCodexRequest,
|
||||
CurrentUser,
|
||||
)
|
||||
from src.infrastructure.repositories import BackendRepository
|
||||
from src.presentation.dependencies import get_repository, require_roles
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["agent-jobs"])
|
||||
internal_router = APIRouter(prefix="/internal/agent-jobs", include_in_schema=False)
|
||||
|
||||
|
||||
class AgentJobCompletionRequest(BaseModel):
|
||||
workspace_path: str | None = None
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
exit_code: int | None = None
|
||||
duration_ms: int | None = Field(default=None, ge=0)
|
||||
output: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/agent-jobs/test-codex",
|
||||
response_model=AgentJobResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def post_test_codex_job(
|
||||
request: AgentJobTestCodexRequest = Body(
|
||||
default_factory=AgentJobTestCodexRequest
|
||||
),
|
||||
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> AgentJobResponse:
|
||||
return create_test_codex_job(repository, request)
|
||||
|
||||
|
||||
@router.get("/agent-jobs", response_model=AgentJobListResponse)
|
||||
def get_agent_jobs(
|
||||
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> AgentJobListResponse:
|
||||
return list_agent_jobs(repository)
|
||||
|
||||
|
||||
@router.get("/agent-jobs/{job_id}", response_model=AgentJobResponse)
|
||||
def get_agent_job_route(
|
||||
job_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> AgentJobResponse:
|
||||
try:
|
||||
return get_agent_job(repository, job_id)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.post(
|
||||
"/agent-jobs/{job_id}/retry",
|
||||
response_model=AgentJobResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def post_retry_agent_job(
|
||||
job_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> AgentJobResponse:
|
||||
try:
|
||||
return retry_agent_job(repository, job_id)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.post("/agent-jobs/{job_id}/cancel", response_model=AgentJobResponse)
|
||||
def post_cancel_agent_job(
|
||||
job_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> AgentJobResponse:
|
||||
try:
|
||||
return cancel_agent_job(repository, job_id)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@internal_router.post("/claim", response_model=None)
|
||||
def post_claim_agent_job(
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
):
|
||||
claimed = claim_next_agent_job(repository)
|
||||
if claimed is None:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
return claimed
|
||||
|
||||
|
||||
@internal_router.post("/{job_id}/complete", response_model=AgentJobResponse)
|
||||
def post_complete_agent_job(
|
||||
job_id: UUID,
|
||||
request: AgentJobCompletionRequest,
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> AgentJobResponse:
|
||||
try:
|
||||
return complete_agent_job(
|
||||
repository,
|
||||
job_id=job_id,
|
||||
workspace_path=request.workspace_path,
|
||||
stdout=request.stdout,
|
||||
stderr=request.stderr,
|
||||
exit_code=request.exit_code,
|
||||
duration_ms=request.duration_ms,
|
||||
output=request.output,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from src.application.articles import (
|
||||
create_article,
|
||||
get_article_detail,
|
||||
list_articles,
|
||||
)
|
||||
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
||||
from src.domain.contracts import (
|
||||
ArticleCreateRequest,
|
||||
ArticleCreateResponse,
|
||||
ArticleDetailResponse,
|
||||
ArticleListResponse,
|
||||
CurrentUser,
|
||||
)
|
||||
from src.infrastructure.repositories import BackendRepository
|
||||
from src.presentation.dependencies import get_repository, require_roles
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["articles"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles",
|
||||
response_model=ArticleCreateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def post_article(
|
||||
request: ArticleCreateRequest,
|
||||
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> ArticleCreateResponse:
|
||||
try:
|
||||
return create_article(
|
||||
repository,
|
||||
request=request,
|
||||
current_user=current_user,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid target_site_id",
|
||||
) from error
|
||||
|
||||
|
||||
@router.get("/articles", response_model=ArticleListResponse)
|
||||
def list_articles_route(
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> ArticleListResponse:
|
||||
return list_articles(repository)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/articles/{article_id}",
|
||||
response_model=ArticleDetailResponse,
|
||||
)
|
||||
def get_article(
|
||||
article_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> ArticleDetailResponse:
|
||||
try:
|
||||
return get_article_detail(repository=repository, article_id=article_id)
|
||||
except LookupError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Not Found",
|
||||
) from error
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from src.domain.contracts import CurrentUser, CurrentUserResponse
|
||||
from src.presentation.dependencies import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["auth"])
|
||||
|
||||
|
||||
@router.get("/me", response_model=CurrentUserResponse)
|
||||
def get_me(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CurrentUserResponse:
|
||||
return CurrentUserResponse(user=current_user)
|
||||
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from src.application.boundary_questions import (
|
||||
create_plan_generation_job,
|
||||
generate_boundary_questions,
|
||||
list_boundary_questions,
|
||||
submit_boundary_questions,
|
||||
update_boundary_question,
|
||||
)
|
||||
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
||||
from src.domain.contracts import (
|
||||
AgentJobListResponse,
|
||||
ArticleCreateResponse,
|
||||
BoundaryQuestionListResponse,
|
||||
BoundaryQuestionResponse,
|
||||
BoundaryQuestionUpdateRequest,
|
||||
CurrentUser,
|
||||
)
|
||||
from src.infrastructure.repositories import BackendRepository
|
||||
from src.presentation.dependencies import get_repository, require_roles
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["boundary-questions"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles/{article_id}/boundary-questions/generate",
|
||||
response_model=BoundaryQuestionListResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def post_generate_boundary_questions(
|
||||
article_id: UUID,
|
||||
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> BoundaryQuestionListResponse:
|
||||
try:
|
||||
return generate_boundary_questions(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
current_user=current_user,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Not Found",
|
||||
) from error
|
||||
|
||||
|
||||
@router.get(
|
||||
"/articles/{article_id}/boundary-questions",
|
||||
response_model=BoundaryQuestionListResponse,
|
||||
)
|
||||
def get_boundary_questions(
|
||||
article_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> BoundaryQuestionListResponse:
|
||||
try:
|
||||
return list_boundary_questions(repository, article_id=article_id)
|
||||
except LookupError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Not Found",
|
||||
) from error
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/articles/{article_id}/boundary-questions/{question_id}",
|
||||
response_model=BoundaryQuestionResponse,
|
||||
)
|
||||
def patch_boundary_question(
|
||||
article_id: UUID,
|
||||
question_id: UUID,
|
||||
request: BoundaryQuestionUpdateRequest,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> BoundaryQuestionResponse:
|
||||
try:
|
||||
return update_boundary_question(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
question_id=question_id,
|
||||
request=request,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Not Found",
|
||||
) from error
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles/{article_id}/boundary-questions/submit",
|
||||
response_model=ArticleCreateResponse,
|
||||
)
|
||||
def post_submit_boundary_questions(
|
||||
article_id: UUID,
|
||||
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> ArticleCreateResponse:
|
||||
try:
|
||||
article = submit_boundary_questions(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
current_user=current_user,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Not Found",
|
||||
) from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(error),
|
||||
) from error
|
||||
|
||||
return ArticleCreateResponse(article=article)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles/{article_id}/plans/generate",
|
||||
response_model=AgentJobListResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
def post_generate_plan(
|
||||
article_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> AgentJobListResponse:
|
||||
try:
|
||||
return create_plan_generation_job(repository, article_id=article_id)
|
||||
except LookupError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Not Found",
|
||||
) from error
|
||||
except PermissionError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(error),
|
||||
) from error
|
||||
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from src.application.plans import (
|
||||
approve_plan,
|
||||
edit_plan,
|
||||
generate_plan,
|
||||
get_plan,
|
||||
list_plans,
|
||||
request_plan_revision,
|
||||
)
|
||||
from src.application.research import list_research_runs, start_research_run
|
||||
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
||||
from src.domain.contracts import (
|
||||
CurrentUser,
|
||||
PlanListResponse,
|
||||
PlanResponse,
|
||||
PlanRevisionRequest,
|
||||
PlanUpdateRequest,
|
||||
ResearchListResponse,
|
||||
ResearchStartResponse,
|
||||
ReviewActionResponse,
|
||||
)
|
||||
from src.infrastructure.object_storage import open_object_storage_client
|
||||
from src.infrastructure.repositories import BackendRepository
|
||||
from src.presentation.dependencies import get_repository, require_roles
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["plans"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles/{article_id}/plan/generate",
|
||||
response_model=PlanResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def post_generate_plan(
|
||||
article_id: UUID,
|
||||
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> PlanResponse:
|
||||
try:
|
||||
return generate_plan(repository, article_id=article_id, current_user=current_user)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
except PermissionError as error:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||
|
||||
|
||||
@router.get("/articles/{article_id}/plans", response_model=PlanListResponse)
|
||||
def get_plans(
|
||||
article_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> PlanListResponse:
|
||||
try:
|
||||
return list_plans(repository, article_id=article_id)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.get("/articles/{article_id}/plans/{plan_id}", response_model=PlanResponse)
|
||||
def get_plan_route(
|
||||
article_id: UUID,
|
||||
plan_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> PlanResponse:
|
||||
try:
|
||||
return get_plan(repository, article_id=article_id, plan_id=plan_id)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/articles/{article_id}/plans/{plan_id}",
|
||||
response_model=PlanResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def patch_plan(
|
||||
article_id: UUID,
|
||||
plan_id: UUID,
|
||||
request: PlanUpdateRequest,
|
||||
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> PlanResponse:
|
||||
try:
|
||||
return edit_plan(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
plan_id=plan_id,
|
||||
request=request,
|
||||
current_user=current_user,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles/{article_id}/plans/{plan_id}/approve",
|
||||
response_model=ReviewActionResponse,
|
||||
)
|
||||
def post_approve_plan(
|
||||
article_id: UUID,
|
||||
plan_id: UUID,
|
||||
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> ReviewActionResponse:
|
||||
try:
|
||||
return approve_plan(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
plan_id=plan_id,
|
||||
current_user=current_user,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles/{article_id}/plans/{plan_id}/request-revision",
|
||||
response_model=ReviewActionResponse,
|
||||
)
|
||||
def post_request_plan_revision(
|
||||
article_id: UUID,
|
||||
plan_id: UUID,
|
||||
request: PlanRevisionRequest,
|
||||
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> ReviewActionResponse:
|
||||
try:
|
||||
return request_plan_revision(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
plan_id=plan_id,
|
||||
request=request,
|
||||
current_user=current_user,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles/{article_id}/research/start",
|
||||
response_model=ResearchStartResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def post_start_research(
|
||||
article_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> ResearchStartResponse:
|
||||
try:
|
||||
return start_research_run(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
object_storage=open_object_storage_client(),
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
except PermissionError as error:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||
|
||||
|
||||
@router.get(
|
||||
"/articles/{article_id}/research",
|
||||
response_model=ResearchListResponse,
|
||||
)
|
||||
def get_research(
|
||||
article_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> ResearchListResponse:
|
||||
try:
|
||||
return list_research_runs(repository, article_id=article_id)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from src.application.site_config import (
|
||||
activate_script_config_version,
|
||||
create_script_config_version,
|
||||
create_target_site,
|
||||
get_target_site,
|
||||
list_script_config_versions,
|
||||
rollback_script_config_version,
|
||||
update_target_site,
|
||||
)
|
||||
from src.domain.auth import ADMIN_ROLES, EDITOR_OR_ADMIN_ROLES
|
||||
from src.domain.contracts import (
|
||||
CurrentUser,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionListResponse,
|
||||
ScriptConfigVersionResponse,
|
||||
TargetSiteConfigCreateRequest,
|
||||
TargetSiteConfigResponse,
|
||||
TargetSiteConfigUpdateRequest,
|
||||
)
|
||||
from src.infrastructure.repositories import BackendRepository
|
||||
from src.presentation.dependencies import get_repository, require_roles
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["sites"])
|
||||
|
||||
|
||||
@router.get("/sites", response_model=list[TargetSiteConfigResponse])
|
||||
def list_sites(
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> list[TargetSiteConfigResponse]:
|
||||
return [TargetSiteConfigResponse(site=site) for site in repository.target_sites.list()]
|
||||
|
||||
|
||||
@router.get("/sites/{site_id}", response_model=TargetSiteConfigResponse)
|
||||
def get_site(
|
||||
site_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> TargetSiteConfigResponse:
|
||||
try:
|
||||
return get_target_site(repository, site_id)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sites",
|
||||
response_model=TargetSiteConfigResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def post_site(
|
||||
request: TargetSiteConfigCreateRequest,
|
||||
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> TargetSiteConfigResponse:
|
||||
return create_target_site(repository, request)
|
||||
|
||||
|
||||
@router.patch("/sites/{site_id}", response_model=TargetSiteConfigResponse)
|
||||
def patch_site(
|
||||
site_id: UUID,
|
||||
request: TargetSiteConfigUpdateRequest,
|
||||
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> TargetSiteConfigResponse:
|
||||
try:
|
||||
return update_target_site(repository, site_id, request)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sites/{site_id}/publishing-config/versions",
|
||||
response_model=ScriptConfigVersionListResponse,
|
||||
)
|
||||
def get_script_config_versions(
|
||||
site_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> ScriptConfigVersionListResponse:
|
||||
try:
|
||||
versions = list_script_config_versions(repository, site_id)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
return ScriptConfigVersionListResponse(versions=versions)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sites/{site_id}/publishing-config/versions",
|
||||
response_model=ScriptConfigVersionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def post_script_config_version(
|
||||
site_id: UUID,
|
||||
request: ScriptConfigVersionCreateRequest,
|
||||
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> ScriptConfigVersionResponse:
|
||||
try:
|
||||
return create_script_config_version(
|
||||
repository,
|
||||
site_id=site_id,
|
||||
current_user=current_user,
|
||||
request=request,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sites/{site_id}/publishing-config/versions/{version_id}/activate",
|
||||
response_model=ScriptConfigVersionResponse,
|
||||
)
|
||||
def post_activate_script_config_version(
|
||||
site_id: UUID,
|
||||
version_id: UUID,
|
||||
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> ScriptConfigVersionResponse:
|
||||
try:
|
||||
return activate_script_config_version(
|
||||
repository,
|
||||
site_id=site_id,
|
||||
version_id=version_id,
|
||||
current_user=current_user,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sites/{site_id}/publishing-config/versions/{version_id}/rollback",
|
||||
response_model=ScriptConfigVersionResponse,
|
||||
)
|
||||
def post_rollback_script_config_version(
|
||||
site_id: UUID,
|
||||
version_id: UUID,
|
||||
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> ScriptConfigVersionResponse:
|
||||
try:
|
||||
return rollback_script_config_version(
|
||||
repository,
|
||||
site_id=site_id,
|
||||
version_id=version_id,
|
||||
current_user=current_user,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from src.domain.contracts import ( # noqa: E402
|
||||
ARTICLE_WORKFLOW_STATUSES,
|
||||
ARTICLE_WORKFLOW_TRANSITIONS,
|
||||
ArticleCreateRequest,
|
||||
ArticleWorkflowStatus,
|
||||
Role,
|
||||
UserSummary,
|
||||
)
|
||||
|
||||
|
||||
class DomainContractTest(unittest.TestCase):
|
||||
def test_role_enum_is_canonical(self) -> None:
|
||||
self.assertEqual(["ADMIN", "EDITOR"], [role.value for role in Role])
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
UserSummary.model_validate(
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"display_name": "Writer",
|
||||
"role": "WRITER",
|
||||
}
|
||||
)
|
||||
|
||||
def test_article_create_rejects_missing_required_fields(self) -> None:
|
||||
with self.assertRaises(ValidationError) as error:
|
||||
ArticleCreateRequest.model_validate({})
|
||||
|
||||
missing_fields = {
|
||||
issue["loc"][0]
|
||||
for issue in error.exception.errors()
|
||||
if issue["type"] == "missing"
|
||||
}
|
||||
self.assertEqual({"brief_description", "target_site_id"}, missing_fields)
|
||||
|
||||
def test_workflow_statuses_and_transitions_are_shared_constants(self) -> None:
|
||||
self.assertEqual(
|
||||
[
|
||||
"ARTICLE_BRIEF_CREATED",
|
||||
"BOUNDARY_QUESTIONS_GENERATED",
|
||||
"BOUNDARY_ANSWERS_SUBMITTED",
|
||||
"PLAN_GENERATED",
|
||||
"PLAN_REVIEW_REQUIRED",
|
||||
"PLAN_REVISION_REQUIRED",
|
||||
"RESEARCH_RUNNING",
|
||||
"EVIDENCE_MATRIX_READY",
|
||||
"PARALLEL_PRODUCTION_RUNNING",
|
||||
"DRAFT_ASSEMBLED",
|
||||
"SEO_AND_LANGUAGE_REVIEW_READY",
|
||||
"FINAL_REVIEW_REQUIRED",
|
||||
"FINAL_REVISION_REQUIRED",
|
||||
"PUBLISH_DRY_RUN_REQUIRED",
|
||||
"PUBLISH_COMMIT_READY",
|
||||
"PUBLISH_COMMIT_CREATED",
|
||||
],
|
||||
[status.value for status in ARTICLE_WORKFLOW_STATUSES],
|
||||
)
|
||||
self.assertEqual(set(ArticleWorkflowStatus), set(ARTICLE_WORKFLOW_TRANSITIONS))
|
||||
for status, next_statuses in ARTICLE_WORKFLOW_TRANSITIONS.items():
|
||||
self.assertIsInstance(status, ArticleWorkflowStatus)
|
||||
for next_status in next_statuses:
|
||||
self.assertIsInstance(next_status, ArticleWorkflowStatus)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
BACKEND_ROOT = REPO_ROOT / "apps" / "backend"
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from src.presentation.main import app # noqa: E402
|
||||
|
||||
|
||||
class GeneratedContractArtifactsTest(unittest.TestCase):
|
||||
def test_openapi_snapshot_matches_backend_openapi(self) -> None:
|
||||
snapshot_path = REPO_ROOT / "packages" / "shared" / "openapi.json"
|
||||
snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertEqual(app.openapi(), snapshot)
|
||||
|
||||
def test_typescript_types_are_marked_generated(self) -> None:
|
||||
types_path = REPO_ROOT / "packages" / "shared" / "src" / "api-types.ts"
|
||||
content = types_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertTrue(
|
||||
content.startswith(
|
||||
"// Generated from backend OpenAPI by "
|
||||
"scripts/generate_openapi_contracts.py."
|
||||
)
|
||||
)
|
||||
self.assertIn("export type ArticleCreateRequest", content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
|
||||
def _module_available(name: str) -> bool:
|
||||
try:
|
||||
return find_spec(name) is not None
|
||||
except (ModuleNotFoundError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _install_optional_dependency_stubs() -> None:
|
||||
# Health routes import external clients; this contract test only reads OpenAPI.
|
||||
if not _module_available("boto3"):
|
||||
boto3 = types.ModuleType("boto3")
|
||||
boto3.client = lambda *args, **kwargs: None
|
||||
sys.modules["boto3"] = boto3
|
||||
|
||||
if not _module_available("psycopg"):
|
||||
psycopg = types.ModuleType("psycopg")
|
||||
psycopg.connect = lambda *args, **kwargs: None
|
||||
sys.modules["psycopg"] = psycopg
|
||||
|
||||
if not _module_available("redis"):
|
||||
redis = types.ModuleType("redis")
|
||||
|
||||
class Redis:
|
||||
@staticmethod
|
||||
def from_url(*args: Any, **kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
redis.Redis = Redis
|
||||
sys.modules["redis"] = redis
|
||||
|
||||
if not _module_available("botocore.config"):
|
||||
botocore = types.ModuleType("botocore")
|
||||
config = types.ModuleType("botocore.config")
|
||||
|
||||
class Config:
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
config.Config = Config
|
||||
sys.modules["botocore"] = botocore
|
||||
sys.modules["botocore.config"] = config
|
||||
|
||||
|
||||
_install_optional_dependency_stubs()
|
||||
|
||||
from src.presentation.main import app # noqa: E402
|
||||
|
||||
|
||||
class BackendPublicOpenApiContractTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.openapi = app.openapi()
|
||||
self.schemas = self.openapi.get("components", {}).get("schemas", {})
|
||||
|
||||
def test_article_create_request_contract_is_public_and_rejects_invalid_payloads(
|
||||
self,
|
||||
) -> None:
|
||||
failures: list[str] = []
|
||||
|
||||
article_schema = self.schemas.get("ArticleCreateRequest")
|
||||
if article_schema is None:
|
||||
failures.append("components.schemas.ArticleCreateRequest is missing")
|
||||
else:
|
||||
required = set(article_schema.get("required", []))
|
||||
for field in ("brief_description", "target_site_id"):
|
||||
if field not in required:
|
||||
failures.append(f"ArticleCreateRequest must require {field!r}")
|
||||
|
||||
invalid_article_errors = self._validation_errors(
|
||||
schema_name="ArticleCreateRequest",
|
||||
payload={},
|
||||
)
|
||||
if not self._has_required_error(invalid_article_errors, "brief_description"):
|
||||
failures.append(
|
||||
"ArticleCreateRequest contract must reject payloads missing "
|
||||
"'brief_description'"
|
||||
)
|
||||
if not self._has_required_error(invalid_article_errors, "target_site_id"):
|
||||
failures.append(
|
||||
"ArticleCreateRequest contract must reject payloads missing "
|
||||
"'target_site_id'"
|
||||
)
|
||||
|
||||
request_ref = self._json_request_schema_ref(path="/api/articles", method="post")
|
||||
if request_ref != "#/components/schemas/ArticleCreateRequest":
|
||||
failures.append(
|
||||
"POST /api/articles must expose ArticleCreateRequest as its JSON "
|
||||
f"request body; got {request_ref or 'no request body'}"
|
||||
)
|
||||
|
||||
role_schema = self.schemas.get("Role")
|
||||
if role_schema is None:
|
||||
failures.append("components.schemas.Role is missing")
|
||||
else:
|
||||
role_values = self._enum_values(role_schema)
|
||||
if role_values != ["ADMIN", "EDITOR"]:
|
||||
failures.append(
|
||||
"Role enum must be exactly ['ADMIN', 'EDITOR']; "
|
||||
f"got {role_values!r}"
|
||||
)
|
||||
if not self._has_enum_error("Role", "WRITER"):
|
||||
failures.append("Role contract must reject invalid value 'WRITER'")
|
||||
|
||||
workflow_status_schema = self.schemas.get("ArticleWorkflowStatus")
|
||||
if workflow_status_schema is None:
|
||||
failures.append("components.schemas.ArticleWorkflowStatus is missing")
|
||||
elif not self._has_enum_error("ArticleWorkflowStatus", "NOT_A_STATUS"):
|
||||
failures.append(
|
||||
"ArticleWorkflowStatus contract must reject invalid workflow statuses"
|
||||
)
|
||||
|
||||
self.assertEqual([], failures)
|
||||
|
||||
def _json_request_schema_ref(self, *, path: str, method: str) -> str | None:
|
||||
operation = self.openapi.get("paths", {}).get(path, {}).get(method, {})
|
||||
content = operation.get("requestBody", {}).get("content", {})
|
||||
schema = content.get("application/json", {}).get("schema", {})
|
||||
return schema.get("$ref")
|
||||
|
||||
def _has_required_error(self, errors: list[str], field: str) -> bool:
|
||||
return any(error == f"missing required field: {field}" for error in errors)
|
||||
|
||||
def _has_enum_error(self, schema_name: str, value: str) -> bool:
|
||||
return f"invalid enum value: {value}" in self._validation_errors(
|
||||
schema_name=schema_name,
|
||||
payload=value,
|
||||
)
|
||||
|
||||
def _validation_errors(self, *, schema_name: str, payload: Any) -> list[str]:
|
||||
schema = self.schemas.get(schema_name)
|
||||
if schema is None:
|
||||
return [f"missing schema: {schema_name}"]
|
||||
|
||||
enum_values = self._enum_values(schema)
|
||||
if enum_values is not None:
|
||||
return [] if payload in enum_values else [f"invalid enum value: {payload}"]
|
||||
|
||||
if schema.get("type") != "object" or not isinstance(payload, dict):
|
||||
return []
|
||||
|
||||
required = schema.get("required", [])
|
||||
return [
|
||||
f"missing required field: {field}"
|
||||
for field in required
|
||||
if field not in payload
|
||||
]
|
||||
|
||||
def _enum_values(self, schema: dict[str, Any]) -> list[str] | None:
|
||||
enum_values = schema.get("enum")
|
||||
if isinstance(enum_values, list):
|
||||
return enum_values
|
||||
|
||||
for composite_key in ("allOf", "anyOf", "oneOf"):
|
||||
for item in schema.get(composite_key, []):
|
||||
resolved = self._resolve_schema(item)
|
||||
values = self._enum_values(resolved)
|
||||
if values is not None:
|
||||
return values
|
||||
|
||||
return None
|
||||
|
||||
def _resolve_schema(self, schema: dict[str, Any]) -> dict[str, Any]:
|
||||
ref = schema.get("$ref")
|
||||
if not isinstance(ref, str):
|
||||
return schema
|
||||
|
||||
prefix = "#/components/schemas/"
|
||||
if not ref.startswith(prefix):
|
||||
return schema
|
||||
|
||||
return self.schemas.get(ref.removeprefix(prefix), schema)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||
from src.presentation.dependencies import get_repository # noqa: E402
|
||||
from src.presentation.main import app # noqa: E402
|
||||
|
||||
|
||||
DEMO_ADMIN_EMAIL = "admin@example.com"
|
||||
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||
|
||||
|
||||
class AgentJobQueuePublicApiTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||
self.workspace_root = Path(self.tmp_dir.name) / "runner-workspaces"
|
||||
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'agent-jobs.db'}"
|
||||
self.repository = open_backend_repository(dsn)
|
||||
self.repository.setup()
|
||||
seed_reference_data(self.repository)
|
||||
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||
self.client = TestClient(app)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
app.dependency_overrides.clear()
|
||||
self.tmp_dir.cleanup()
|
||||
|
||||
def test_admin_enqueues_and_fake_runner_completes_test_codex_job(self) -> None:
|
||||
create_response = self.client.post(
|
||||
"/api/agent-jobs/test-codex",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
)
|
||||
|
||||
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||
created = create_response.json()["job"]
|
||||
self.assertEqual("TEST_CODEX", created["job_type"])
|
||||
self.assertEqual("QUEUED", created["status"])
|
||||
self.assertEqual(1, created["attempt"])
|
||||
|
||||
self._run_fake_runner_once()
|
||||
|
||||
get_response = self.client.get(
|
||||
f"/api/agent-jobs/{created['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
)
|
||||
list_response = self.client.get(
|
||||
"/api/agent-jobs",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
)
|
||||
|
||||
self.assertEqual(200, get_response.status_code, get_response.text)
|
||||
completed = get_response.json()["job"]
|
||||
self.assertEqual("SUCCEEDED", completed["status"])
|
||||
self.assertTrue(completed["workspace_path"])
|
||||
self.assertIn("fake Codex runner completed", completed["stdout"])
|
||||
self.assertEqual("", completed["stderr"])
|
||||
self.assertEqual(0, completed["exit_code"])
|
||||
self.assertGreaterEqual(completed["duration_ms"], 0)
|
||||
self.assertEqual([{"path": "outputs/result.json", "content_hash": None}], completed["output_files"])
|
||||
|
||||
self.assertEqual(200, list_response.status_code, list_response.text)
|
||||
self.assertIn(
|
||||
created["id"],
|
||||
[job["id"] for job in list_response.json()["jobs"]],
|
||||
)
|
||||
self.assertTrue((Path(completed["workspace_path"]) / "outputs" / "result.json").is_file())
|
||||
|
||||
def test_retry_creates_traceable_child_attempt(self) -> None:
|
||||
failed = self.client.post(
|
||||
"/api/agent-jobs/test-codex",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json={"fake_result": "invalid_schema"},
|
||||
).json()["job"]
|
||||
|
||||
self._run_fake_runner_once()
|
||||
|
||||
failed_response = self.client.get(
|
||||
f"/api/agent-jobs/{failed['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
)
|
||||
self.assertEqual("FAILED", failed_response.json()["job"]["status"])
|
||||
self.assertEqual(
|
||||
"FAILED_SCHEMA_VALIDATION",
|
||||
failed_response.json()["job"]["error_category"],
|
||||
)
|
||||
|
||||
retry_response = self.client.post(
|
||||
f"/api/agent-jobs/{failed['id']}/retry",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
)
|
||||
|
||||
self.assertEqual(201, retry_response.status_code, retry_response.text)
|
||||
retry = retry_response.json()["job"]
|
||||
self.assertEqual("QUEUED", retry["status"])
|
||||
self.assertEqual(failed["id"], retry["parent_job_id"])
|
||||
self.assertEqual(2, retry["attempt"])
|
||||
|
||||
def test_cancelled_job_is_not_claimed_or_completed_by_runner(self) -> None:
|
||||
created = self.client.post(
|
||||
"/api/agent-jobs/test-codex",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
).json()["job"]
|
||||
|
||||
cancel_response = self.client.post(
|
||||
f"/api/agent-jobs/{created['id']}/cancel",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, cancel_response.status_code, cancel_response.text)
|
||||
self.assertEqual("CANCELLED", cancel_response.json()["job"]["status"])
|
||||
|
||||
self._run_fake_runner_once()
|
||||
|
||||
get_response = self.client.get(
|
||||
f"/api/agent-jobs/{created['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
)
|
||||
job = get_response.json()["job"]
|
||||
self.assertEqual("CANCELLED", job["status"])
|
||||
self.assertEqual([], job["output_files"])
|
||||
self.assertIsNone(job["workspace_path"])
|
||||
|
||||
def _run_fake_runner_once(self) -> None:
|
||||
claim_response = self.client.post("/internal/agent-jobs/claim")
|
||||
if claim_response.status_code == 204:
|
||||
return
|
||||
|
||||
self.assertEqual(200, claim_response.status_code, claim_response.text)
|
||||
job = claim_response.json()["job"]
|
||||
workspace_path = self._write_fake_workspace(job)
|
||||
fake_output: dict[str, Any]
|
||||
if job["agent_profile"] == "fake-invalid-schema":
|
||||
fake_output = {"status": "NOT_A_STATUS"}
|
||||
else:
|
||||
fake_output = {
|
||||
"status": "SUCCEEDED",
|
||||
"output_files": [{"path": "outputs/result.json"}],
|
||||
"payload": {"job_id": job["id"], "mode": "fake"},
|
||||
}
|
||||
|
||||
complete_response = self.client.post(
|
||||
f"/internal/agent-jobs/{job['id']}/complete",
|
||||
json={
|
||||
"workspace_path": str(workspace_path),
|
||||
"stdout": "fake Codex runner completed\n",
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
"duration_ms": 1,
|
||||
"output": fake_output,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, complete_response.status_code, complete_response.text)
|
||||
|
||||
def _write_fake_workspace(self, job: dict[str, Any]) -> Path:
|
||||
workspace_path = self.workspace_root / job["id"]
|
||||
if workspace_path.exists():
|
||||
shutil.rmtree(workspace_path)
|
||||
(workspace_path / "inputs").mkdir(parents=True)
|
||||
(workspace_path / "logs").mkdir()
|
||||
(workspace_path / "outputs").mkdir()
|
||||
(workspace_path / "inputs" / "job.json").write_text(str(job), encoding="utf-8")
|
||||
(workspace_path / "logs" / "stdout.log").write_text(
|
||||
"fake Codex runner completed\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(workspace_path / "logs" / "stderr.log").write_text("", encoding="utf-8")
|
||||
(workspace_path / "outputs" / "result.json").write_text(
|
||||
'{"status":"ok"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
return workspace_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from src.presentation.main import app # noqa: E402
|
||||
|
||||
|
||||
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||
|
||||
|
||||
class ArticleIntakePublicApiTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.client = TestClient(app)
|
||||
|
||||
def test_editor_creates_article_and_reads_dashboard_detail_timeline(self) -> None:
|
||||
headers = {DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}
|
||||
|
||||
editor_response = self.client.get("/api/me", headers=headers)
|
||||
self.assertEqual(200, editor_response.status_code, editor_response.text)
|
||||
editor_user_id = editor_response.json()["user"]["id"]
|
||||
|
||||
sites_response = self.client.get("/api/sites", headers=headers)
|
||||
self.assertEqual(200, sites_response.status_code, sites_response.text)
|
||||
target_site = sites_response.json()[0]["site"]
|
||||
|
||||
brief_description = (
|
||||
"Create an editorial control guide for AI content intake "
|
||||
f"{uuid4()}."
|
||||
)
|
||||
create_response = self.client.post(
|
||||
"/api/articles",
|
||||
headers=headers,
|
||||
json={
|
||||
"target_site_id": target_site["id"],
|
||||
"brief_description": brief_description,
|
||||
"working_title": "AI Content Intake Controls",
|
||||
"content_type": "longform_guide",
|
||||
"primary_keyword": "AI content intake",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||
created_article = create_response.json()["article"]
|
||||
self.assertEqual("ARTICLE_BRIEF_CREATED", created_article["status"])
|
||||
self.assertEqual(target_site["id"], created_article["target_site_id"])
|
||||
self.assertEqual(brief_description, created_article["brief_description"])
|
||||
|
||||
article_id = created_article["id"]
|
||||
list_response = self.client.get("/api/articles", headers=headers)
|
||||
detail_response = self.client.get(f"/api/articles/{article_id}", headers=headers)
|
||||
|
||||
failures: list[str] = []
|
||||
if list_response.status_code != 200:
|
||||
failures.append(
|
||||
"GET /api/articles should return the persisted dashboard list; "
|
||||
f"got {list_response.status_code}: {list_response.text}"
|
||||
)
|
||||
else:
|
||||
article = self._find_article(list_response.json(), article_id)
|
||||
if article is None:
|
||||
failures.append(
|
||||
"Created article is missing from GET /api/articles dashboard list"
|
||||
)
|
||||
else:
|
||||
self._expect_equal(
|
||||
failures,
|
||||
"dashboard article status",
|
||||
"ARTICLE_BRIEF_CREATED",
|
||||
article.get("status"),
|
||||
)
|
||||
self._expect_equal(
|
||||
failures,
|
||||
"dashboard article target_site_id",
|
||||
target_site["id"],
|
||||
article.get("target_site_id"),
|
||||
)
|
||||
|
||||
if detail_response.status_code != 200:
|
||||
failures.append(
|
||||
"GET /api/articles/{article_id} should return the persisted detail "
|
||||
f"shell; got {detail_response.status_code}: {detail_response.text}"
|
||||
)
|
||||
else:
|
||||
detail = detail_response.json()
|
||||
if not isinstance(detail, dict):
|
||||
failures.append("Article detail should return a JSON object")
|
||||
detail = {}
|
||||
|
||||
detail_article = detail.get("article", {})
|
||||
self._expect_equal(
|
||||
failures,
|
||||
"detail article id",
|
||||
article_id,
|
||||
detail_article.get("id"),
|
||||
)
|
||||
self._expect_equal(
|
||||
failures,
|
||||
"detail article status",
|
||||
"ARTICLE_BRIEF_CREATED",
|
||||
detail_article.get("status"),
|
||||
)
|
||||
|
||||
detail_target_site = detail.get("target_site")
|
||||
if not isinstance(detail_target_site, dict):
|
||||
failures.append("Article detail should include target_site summary")
|
||||
else:
|
||||
self._expect_equal(
|
||||
failures,
|
||||
"detail target_site id",
|
||||
target_site["id"],
|
||||
detail_target_site.get("id"),
|
||||
)
|
||||
self._expect_equal(
|
||||
failures,
|
||||
"detail target_site slug",
|
||||
target_site["slug"],
|
||||
detail_target_site.get("slug"),
|
||||
)
|
||||
|
||||
workflow_events = detail.get("workflow_events")
|
||||
if not isinstance(workflow_events, list):
|
||||
failures.append("Article detail should include workflow_events timeline")
|
||||
else:
|
||||
created_events = [
|
||||
event
|
||||
for event in workflow_events
|
||||
if event.get("event_type") == "ARTICLE_CREATED"
|
||||
]
|
||||
if not created_events:
|
||||
failures.append(
|
||||
"Article detail workflow_events should contain ARTICLE_CREATED"
|
||||
)
|
||||
else:
|
||||
created_event = created_events[0]
|
||||
self._expect_equal(
|
||||
failures,
|
||||
"ARTICLE_CREATED article_id",
|
||||
article_id,
|
||||
created_event.get("article_id"),
|
||||
)
|
||||
self._expect_equal(
|
||||
failures,
|
||||
"ARTICLE_CREATED to_status",
|
||||
"ARTICLE_BRIEF_CREATED",
|
||||
created_event.get("to_status"),
|
||||
)
|
||||
self._expect_equal(
|
||||
failures,
|
||||
"ARTICLE_CREATED actor_user_id",
|
||||
editor_user_id,
|
||||
created_event.get("actor_user_id"),
|
||||
)
|
||||
if not created_event.get("created_at"):
|
||||
failures.append("ARTICLE_CREATED should include created_at")
|
||||
|
||||
self.assertEqual([], failures)
|
||||
|
||||
def test_editor_cannot_create_article_with_unknown_target_site(self) -> None:
|
||||
response = self.client.post(
|
||||
"/api/articles",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"target_site_id": "00000000-0000-0000-0000-000000000000",
|
||||
"brief_description": "Intake target-site guardrail check.",
|
||||
"content_type": "longform_guide",
|
||||
"primary_keyword": "integration",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
self.assertEqual("Invalid target_site_id", response.json()["detail"])
|
||||
|
||||
def _find_article(
|
||||
self,
|
||||
response_body: Any,
|
||||
article_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
if not isinstance(response_body, dict):
|
||||
return None
|
||||
|
||||
articles = response_body.get("articles")
|
||||
if not isinstance(articles, list):
|
||||
return None
|
||||
|
||||
return next(
|
||||
(
|
||||
article
|
||||
for article in articles
|
||||
if isinstance(article, dict) and article.get("id") == article_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def _expect_equal(
|
||||
self,
|
||||
failures: list[str],
|
||||
label: str,
|
||||
expected: object,
|
||||
actual: object,
|
||||
) -> None:
|
||||
if actual != expected:
|
||||
failures.append(f"{label}: expected {expected!r}, got {actual!r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,381 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from src.presentation.main import app # noqa: E402
|
||||
|
||||
|
||||
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||
DEMO_ADMIN_EMAIL = "admin@example.com"
|
||||
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||
|
||||
|
||||
class AuthAuthorizationPublicApiTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.client = TestClient(app)
|
||||
|
||||
def test_me_returns_selected_demo_user_and_role(self) -> None:
|
||||
response = self.client.get(
|
||||
"/api/me",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
)
|
||||
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
self.assertEqual(DEMO_ADMIN_EMAIL, response.json()["user"]["email"])
|
||||
self.assertEqual("ADMIN", response.json()["user"]["role"])
|
||||
|
||||
def test_missing_and_unknown_demo_users_are_rejected_consistently(self) -> None:
|
||||
missing_response = self.client.get("/api/me")
|
||||
unknown_response = self.client.get(
|
||||
"/api/me",
|
||||
headers={DEMO_USER_EMAIL_HEADER: "missing@example.com"},
|
||||
)
|
||||
|
||||
self.assertEqual(401, missing_response.status_code, missing_response.text)
|
||||
self.assertEqual(401, unknown_response.status_code, unknown_response.text)
|
||||
self.assertEqual({"detail": "Unauthorized"}, missing_response.json())
|
||||
self.assertEqual({"detail": "Unauthorized"}, unknown_response.json())
|
||||
|
||||
def test_editor_cannot_create_target_site_config(self) -> None:
|
||||
response = self.client.post(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json=self._site_payload("editorial_ops_blog"),
|
||||
)
|
||||
|
||||
self.assertEqual(403, response.status_code, response.text)
|
||||
self.assertEqual({"detail": "Forbidden"}, response.json())
|
||||
|
||||
def test_admin_can_create_and_update_target_site_config(self) -> None:
|
||||
create_response = self.client.post(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._site_payload("admin_ops_blog"),
|
||||
)
|
||||
|
||||
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||
site = create_response.json()["site"]
|
||||
self.assertEqual("admin_ops_blog", site["slug"])
|
||||
|
||||
update_response = self.client.patch(
|
||||
f"/api/sites/{site['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json={"brand_voice": "Updated practical editorial operations guidance."},
|
||||
)
|
||||
|
||||
self.assertEqual(200, update_response.status_code, update_response.text)
|
||||
self.assertEqual(
|
||||
"Updated practical editorial operations guidance.",
|
||||
update_response.json()["site"]["brand_voice"],
|
||||
)
|
||||
|
||||
def test_editor_cannot_update_site_or_mutate_script_versions(self) -> None:
|
||||
create_response = self.client.post(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._site_payload("script_editor_denied_blog"),
|
||||
)
|
||||
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||
site_id = create_response.json()["site"]["id"]
|
||||
|
||||
version_response = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._script_version_payload(),
|
||||
)
|
||||
self.assertEqual(201, version_response.status_code, version_response.text)
|
||||
version_id = version_response.json()["version"]["id"]
|
||||
|
||||
site_update_response = self.client.patch(
|
||||
f"/api/sites/{site_id}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={"brand_voice": "Editor should not mutate Admin config."},
|
||||
)
|
||||
version_create_response = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json=self._script_version_payload(),
|
||||
)
|
||||
version_activate_response = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions/{version_id}/activate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
|
||||
self.assertEqual(403, site_update_response.status_code, site_update_response.text)
|
||||
self.assertEqual(
|
||||
403,
|
||||
version_create_response.status_code,
|
||||
version_create_response.text,
|
||||
)
|
||||
self.assertEqual(
|
||||
403,
|
||||
version_activate_response.status_code,
|
||||
version_activate_response.text,
|
||||
)
|
||||
|
||||
def test_editor_cannot_rollback_script_config_version(self) -> None:
|
||||
site_response = self.client.post(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._site_payload("rollback_denied_blog"),
|
||||
)
|
||||
self.assertEqual(201, site_response.status_code, site_response.text)
|
||||
site_id = site_response.json()["site"]["id"]
|
||||
|
||||
first_version = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._script_version_payload(),
|
||||
)
|
||||
self.assertEqual(201, first_version.status_code, first_version.text)
|
||||
version_id = first_version.json()["version"]["id"]
|
||||
|
||||
activate_response = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions/{version_id}/activate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
rollback_response = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions/{version_id}/rollback",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
|
||||
self.assertEqual(403, activate_response.status_code, activate_response.text)
|
||||
self.assertEqual(403, rollback_response.status_code, rollback_response.text)
|
||||
|
||||
def test_admin_can_create_and_activate_script_config_version(self) -> None:
|
||||
site_response = self.client.post(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._site_payload("script_admin_blog"),
|
||||
)
|
||||
self.assertEqual(201, site_response.status_code, site_response.text)
|
||||
site_id = site_response.json()["site"]["id"]
|
||||
|
||||
create_response = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._script_version_payload(),
|
||||
)
|
||||
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||
self.assertEqual("DRAFT", create_response.json()["version"]["status"])
|
||||
|
||||
version_id = create_response.json()["version"]["id"]
|
||||
activate_response = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions/{version_id}/activate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
)
|
||||
|
||||
self.assertEqual(200, activate_response.status_code, activate_response.text)
|
||||
self.assertEqual("ACTIVE", activate_response.json()["version"]["status"])
|
||||
|
||||
def test_admin_can_get_site_by_id(self) -> None:
|
||||
site_response = self.client.post(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._site_payload("admin_get_site_by_id_blog"),
|
||||
)
|
||||
self.assertEqual(201, site_response.status_code, site_response.text)
|
||||
site_id = site_response.json()["site"]["id"]
|
||||
|
||||
get_response = self.client.get(
|
||||
f"/api/sites/{site_id}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
|
||||
self.assertEqual(200, get_response.status_code, get_response.text)
|
||||
self.assertEqual(site_id, get_response.json()["site"]["id"])
|
||||
|
||||
def test_admin_can_create_version_with_rollback_target(self) -> None:
|
||||
site_response = self.client.post(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._site_payload("rollback_target_blog"),
|
||||
)
|
||||
self.assertEqual(201, site_response.status_code, site_response.text)
|
||||
site_id = site_response.json()["site"]["id"]
|
||||
|
||||
baseline = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._script_version_payload(diff={"summary": "baseline"}),
|
||||
)
|
||||
self.assertEqual(201, baseline.status_code, baseline.text)
|
||||
baseline_id = baseline.json()["version"]["id"]
|
||||
|
||||
create_response = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._script_version_payload(
|
||||
diff={"summary": "rollback-aware"},
|
||||
rollback_target_version_id=baseline_id,
|
||||
activate=True,
|
||||
),
|
||||
)
|
||||
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||
created = create_response.json()["version"]
|
||||
|
||||
self.assertEqual("ACTIVE", created["status"])
|
||||
self.assertEqual("rollback-aware", created["diff"].get("summary"))
|
||||
self.assertEqual(baseline_id, created["rollback_target_version_id"])
|
||||
|
||||
versions_response = self.client.get(
|
||||
f"/api/sites/{site_id}/publishing-config/versions",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, versions_response.status_code, versions_response.text)
|
||||
self.assertEqual(2, len(versions_response.json()["versions"]))
|
||||
|
||||
def test_admin_can_rollback_script_config_version(self) -> None:
|
||||
site_response = self.client.post(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._site_payload("rollback_action_blog"),
|
||||
)
|
||||
self.assertEqual(201, site_response.status_code, site_response.text)
|
||||
site_id = site_response.json()["site"]["id"]
|
||||
|
||||
active = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._script_version_payload(
|
||||
diff={"summary": "initial active"},
|
||||
activate=True,
|
||||
),
|
||||
)
|
||||
self.assertEqual(201, active.status_code, active.text)
|
||||
active_id = active.json()["version"]["id"]
|
||||
|
||||
next_version = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._script_version_payload(diff={"summary": "next version"}),
|
||||
)
|
||||
self.assertEqual(201, next_version.status_code, next_version.text)
|
||||
next_id = next_version.json()["version"]["id"]
|
||||
|
||||
self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions/{next_id}/activate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
)
|
||||
|
||||
rollback_response = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions/{active_id}/rollback",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, rollback_response.status_code, rollback_response.text)
|
||||
self.assertEqual(active_id, rollback_response.json()["version"]["id"])
|
||||
self.assertEqual(
|
||||
"ACTIVE",
|
||||
rollback_response.json()["version"]["status"],
|
||||
)
|
||||
self.assertEqual(
|
||||
next_id,
|
||||
rollback_response.json()["version"]["rollback_target_version_id"],
|
||||
)
|
||||
|
||||
get_response = self.client.get(
|
||||
f"/api/sites/{site_id}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, get_response.status_code, get_response.text)
|
||||
self.assertEqual(
|
||||
active_id,
|
||||
get_response.json()["site"]["active_script_config_version_id"],
|
||||
)
|
||||
|
||||
def test_editor_can_create_articles_and_approve_plan_review(self) -> None:
|
||||
sites_response = self.client.get(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, sites_response.status_code, sites_response.text)
|
||||
target_site_id = sites_response.json()[0]["site"]["id"]
|
||||
|
||||
article_response = self.client.post(
|
||||
"/api/articles",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"target_site_id": target_site_id,
|
||||
"brief_description": "Write about editorial AI workflow controls.",
|
||||
"working_title": "Editorial AI Workflow Controls",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(201, article_response.status_code, article_response.text)
|
||||
self.assertEqual(
|
||||
"ARTICLE_BRIEF_CREATED",
|
||||
article_response.json()["article"]["status"],
|
||||
)
|
||||
|
||||
article_id = article_response.json()["article"]["id"]
|
||||
review_response = self.client.post(
|
||||
f"/api/articles/{article_id}/plans/00000000-0000-0000-0000-000000000123/approve",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
|
||||
self.assertEqual(200, review_response.status_code, review_response.text)
|
||||
self.assertEqual("PLAN", review_response.json()["review"]["review_type"])
|
||||
self.assertEqual("APPROVED", review_response.json()["review"]["status"])
|
||||
|
||||
def test_unauthorized_requests_are_rejected_for_protected_mutations(self) -> None:
|
||||
response = self.client.post(
|
||||
"/api/articles",
|
||||
json={
|
||||
"target_site_id": "00000000-0000-0000-0000-000000000001",
|
||||
"brief_description": "No user selected.",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(401, response.status_code, response.text)
|
||||
self.assertEqual({"detail": "Unauthorized"}, response.json())
|
||||
|
||||
def _site_payload(self, slug: str) -> dict[str, object]:
|
||||
return {
|
||||
"name": "Editorial Ops Blog",
|
||||
"slug": slug,
|
||||
"publishing_type": "git_next",
|
||||
"default_language": "en",
|
||||
"brand_voice": "Practical editorial operations guidance.",
|
||||
"audience": "Editorial teams running AI-assisted workflows.",
|
||||
"seo_rules": {"primary_keyword_required": True},
|
||||
"visual_rules": {"hero_style": "product editorial"},
|
||||
"source_rules": {"minimum_sources": 3},
|
||||
"publishing_rules": {
|
||||
"repository_url": "git@github.com:example/editorial-ops.git",
|
||||
"production_branch": "main",
|
||||
"content_format": "mdx",
|
||||
"content_path_template": "content/articles/{slug}.mdx",
|
||||
"asset_path_template": "public/articles/{slug}/{filename}",
|
||||
"frontmatter_mapping": {"title": "title"},
|
||||
"dry_run_renderer": "next-mdx",
|
||||
},
|
||||
}
|
||||
|
||||
def _script_version_payload(
|
||||
self,
|
||||
diff: dict[str, object] | None = None,
|
||||
rollback_target_version_id: str | None = None,
|
||||
activate: bool = False,
|
||||
) -> dict[str, object]:
|
||||
payload = {
|
||||
"publishing_yaml": "target: editorial_ops\nrepository:\n branch: main\n",
|
||||
"transform_script": "export function transformArticle(article) { return article; }\n",
|
||||
"diff": diff or {"summary": "Task 004 authorization fixture."},
|
||||
}
|
||||
if rollback_target_version_id is not None:
|
||||
payload["rollback_target_version_id"] = rollback_target_version_id
|
||||
if activate:
|
||||
payload["activate"] = True
|
||||
return payload
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,196 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||
from src.presentation.dependencies import get_repository # noqa: E402
|
||||
from src.presentation.main import app # noqa: E402
|
||||
|
||||
|
||||
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||
|
||||
|
||||
class BoundaryQuestionsPublicApiTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'boundary-questions.db'}"
|
||||
self.repository = open_backend_repository(dsn)
|
||||
self.repository.setup()
|
||||
seed_reference_data(self.repository)
|
||||
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||
self.client = TestClient(app)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
app.dependency_overrides.clear()
|
||||
self.tmp_dir.cleanup()
|
||||
|
||||
def test_required_unanswered_questions_block_submission_then_submit_updates_workflow(
|
||||
self,
|
||||
) -> None:
|
||||
article_id = self._create_article()
|
||||
|
||||
blocked_plan_response = self.client.post(
|
||||
f"/api/articles/{article_id}/plans/generate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(409, blocked_plan_response.status_code, blocked_plan_response.text)
|
||||
|
||||
generate_response = self.client.post(
|
||||
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, generate_response.status_code, generate_response.text)
|
||||
generated = generate_response.json()
|
||||
questions = generated["questions"]
|
||||
self.assertGreaterEqual(len(questions), 5)
|
||||
self.assertLessEqual(len(questions), 10)
|
||||
self.assertEqual("BOUNDARY_QUESTIONS", generated["job"]["job_type"])
|
||||
self.assertEqual("SUCCEEDED", generated["job"]["status"])
|
||||
|
||||
categories = {question["category"] for question in questions}
|
||||
for category in (
|
||||
"audience",
|
||||
"purpose",
|
||||
"reader_outcome",
|
||||
"depth",
|
||||
"tone",
|
||||
"excluded_topics",
|
||||
"primary_keyword",
|
||||
"competitor_angle",
|
||||
"evidence_standard",
|
||||
"visual_expectations",
|
||||
):
|
||||
self.assertIn(category, categories)
|
||||
|
||||
required_question = next(
|
||||
question for question in questions if question["is_required"]
|
||||
)
|
||||
optional_question = next(
|
||||
question for question in questions if not question["is_required"]
|
||||
)
|
||||
|
||||
patch_response = self.client.patch(
|
||||
f"/api/articles/{article_id}/boundary-questions/{required_question['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={"answer": "Senior editors evaluating automated SEO content."},
|
||||
)
|
||||
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||
self.assertEqual(
|
||||
"Senior editors evaluating automated SEO content.",
|
||||
patch_response.json()["question"]["answer"],
|
||||
)
|
||||
|
||||
optional_patch_response = self.client.patch(
|
||||
f"/api/articles/{article_id}/boundary-questions/{optional_question['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={"answer": "No custom visual constraints."},
|
||||
)
|
||||
self.assertEqual(200, optional_patch_response.status_code, optional_patch_response.text)
|
||||
|
||||
submit_blocked_response = self.client.post(
|
||||
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(409, submit_blocked_response.status_code, submit_blocked_response.text)
|
||||
self.assertIn("required", submit_blocked_response.json()["detail"])
|
||||
|
||||
list_response = self.client.get(
|
||||
f"/api/articles/{article_id}/boundary-questions",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, list_response.status_code, list_response.text)
|
||||
saved_required = self._find_question(
|
||||
list_response.json()["questions"],
|
||||
required_question["id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"Senior editors evaluating automated SEO content.",
|
||||
saved_required["answer"],
|
||||
)
|
||||
|
||||
for question in list_response.json()["questions"]:
|
||||
if question["is_required"] and not question["answer"]:
|
||||
answer_response = self.client.patch(
|
||||
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={"answer": f"Accepted answer for {question['category']}."},
|
||||
)
|
||||
self.assertEqual(200, answer_response.status_code, answer_response.text)
|
||||
|
||||
submit_response = self.client.post(
|
||||
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, submit_response.status_code, submit_response.text)
|
||||
self.assertEqual(
|
||||
"BOUNDARY_ANSWERS_SUBMITTED",
|
||||
submit_response.json()["article"]["status"],
|
||||
)
|
||||
|
||||
detail_response = self.client.get(
|
||||
f"/api/articles/{article_id}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, detail_response.status_code, detail_response.text)
|
||||
detail = detail_response.json()
|
||||
self.assertEqual("BOUNDARY_ANSWERS_SUBMITTED", detail["article"]["status"])
|
||||
self.assertTrue(
|
||||
any(
|
||||
event["event_type"] == "BOUNDARY_ANSWERS_SUBMITTED"
|
||||
for event in detail["workflow_events"]
|
||||
)
|
||||
)
|
||||
|
||||
allowed_plan_response = self.client.post(
|
||||
f"/api/articles/{article_id}/plans/generate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(202, allowed_plan_response.status_code, allowed_plan_response.text)
|
||||
|
||||
def _create_article(self) -> str:
|
||||
site_response = self.client.get(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, site_response.status_code, site_response.text)
|
||||
target_site = site_response.json()[0]["site"]
|
||||
|
||||
response = self.client.post(
|
||||
"/api/articles",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"target_site_id": target_site["id"],
|
||||
"brief_description": "Build a precise comparison of AI content systems.",
|
||||
"working_title": "AI Content Systems",
|
||||
"content_type": "longform_guide",
|
||||
"primary_keyword": "AI content systems",
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, response.status_code, response.text)
|
||||
return response.json()["article"]["id"]
|
||||
|
||||
def _find_question(
|
||||
self,
|
||||
questions: list[dict[str, Any]],
|
||||
question_id: str,
|
||||
) -> dict[str, Any]:
|
||||
for question in questions:
|
||||
if question["id"] == question_id:
|
||||
return question
|
||||
self.fail(f"Question not found: {question_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||
from src.presentation.dependencies import get_repository # noqa: E402
|
||||
from src.presentation.main import app # noqa: E402
|
||||
|
||||
|
||||
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||
|
||||
|
||||
class PlanGenerationReviewPublicApiTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'plans.db'}"
|
||||
self.repository = open_backend_repository(dsn)
|
||||
self.repository.setup()
|
||||
seed_reference_data(self.repository)
|
||||
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||
self.client = TestClient(app)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
app.dependency_overrides.clear()
|
||||
self.tmp_dir.cleanup()
|
||||
|
||||
def test_research_start_is_blocked_until_plan_approval(self) -> None:
|
||||
article_id = self._create_article_with_boundary_answers()
|
||||
|
||||
blocked_research_response = self.client.post(
|
||||
f"/api/articles/{article_id}/research/start",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(409, blocked_research_response.status_code, blocked_research_response.text)
|
||||
|
||||
generate_response = self.client.post(
|
||||
f"/api/articles/{article_id}/plan/generate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, generate_response.status_code, generate_response.text)
|
||||
plan = generate_response.json()["plan"]
|
||||
self.assertEqual(1, plan["version"])
|
||||
self.assertEqual("PENDING_REVIEW", plan["status"])
|
||||
self.assertGreaterEqual(len(plan["sections"]), 4)
|
||||
self.assertTrue(plan["recommended_title"])
|
||||
self.assertTrue(plan["reader_persona"])
|
||||
self.assertTrue(plan["evidence_needs"])
|
||||
self.assertTrue(plan["visual_needs"])
|
||||
for section in plan["sections"]:
|
||||
self.assertTrue(section["purpose"])
|
||||
self.assertTrue(section["key_points"])
|
||||
self.assertTrue(section["evidence_needs"])
|
||||
|
||||
edit_response = self.client.patch(
|
||||
f"/api/articles/{article_id}/plans/{plan['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"recommended_title": "Edited plan title",
|
||||
"tone": "Direct and evidence-led",
|
||||
"audience": "Technical editors",
|
||||
"seo_notes": ["Prioritize comparison intent"],
|
||||
"sections": [
|
||||
{
|
||||
"heading": "Edited section",
|
||||
"purpose": "Set the context",
|
||||
"key_points": ["Point A", "Point B"],
|
||||
"evidence_needs": ["Vendor docs"],
|
||||
"target_word_count": 300,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, edit_response.status_code, edit_response.text)
|
||||
edited_plan = edit_response.json()["plan"]
|
||||
self.assertEqual(2, edited_plan["version"])
|
||||
self.assertEqual("Edited plan title", edited_plan["recommended_title"])
|
||||
|
||||
list_response = self.client.get(
|
||||
f"/api/articles/{article_id}/plans",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, list_response.status_code, list_response.text)
|
||||
self.assertEqual([1, 2], [item["version"] for item in list_response.json()["plans"]])
|
||||
|
||||
revision_response = self.client.post(
|
||||
f"/api/articles/{article_id}/plans/{edited_plan['id']}/request-revision",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={"notes": "Narrow the evidence standard."},
|
||||
)
|
||||
self.assertEqual(200, revision_response.status_code, revision_response.text)
|
||||
self.assertEqual("CHANGES_REQUESTED", revision_response.json()["review"]["status"])
|
||||
|
||||
approve_response = self.client.post(
|
||||
f"/api/articles/{article_id}/plans/{edited_plan['id']}/approve",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, approve_response.status_code, approve_response.text)
|
||||
self.assertEqual("APPROVED", approve_response.json()["review"]["status"])
|
||||
|
||||
allowed_research_response = self.client.post(
|
||||
f"/api/articles/{article_id}/research/start",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, allowed_research_response.status_code, allowed_research_response.text)
|
||||
self.assertEqual("RESEARCH", allowed_research_response.json()["job"]["job_type"])
|
||||
|
||||
detail_response = self.client.get(
|
||||
f"/api/articles/{article_id}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
events = detail_response.json()["workflow_events"]
|
||||
approval_events = [
|
||||
event for event in events if event["event_type"] == "PLAN_APPROVED"
|
||||
]
|
||||
self.assertEqual(1, len(approval_events))
|
||||
self.assertEqual(2, approval_events[0]["payload"]["plan_version"])
|
||||
|
||||
def _create_article_with_boundary_answers(self) -> str:
|
||||
site_response = self.client.get(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, site_response.status_code, site_response.text)
|
||||
target_site = site_response.json()[0]["site"]
|
||||
|
||||
create_response = self.client.post(
|
||||
"/api/articles",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"target_site_id": target_site["id"],
|
||||
"brief_description": "Create an implementation plan for AI content governance.",
|
||||
"working_title": "AI Content Governance",
|
||||
"content_type": "longform_guide",
|
||||
"primary_keyword": "AI content governance",
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||
article_id = create_response.json()["article"]["id"]
|
||||
|
||||
questions_response = self.client.post(
|
||||
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, questions_response.status_code, questions_response.text)
|
||||
for question in questions_response.json()["questions"]:
|
||||
if question["is_required"]:
|
||||
patch_response = self.client.patch(
|
||||
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={"answer": f"Answer for {question['category']}"},
|
||||
)
|
||||
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||
|
||||
submit_response = self.client.post(
|
||||
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, submit_response.status_code, submit_response.text)
|
||||
return article_id
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
BACKEND_ROOT = REPO_ROOT / "apps" / "backend"
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
|
||||
class PostgresAlembicMigrationIntegrationTest(unittest.TestCase):
|
||||
def test_alembic_upgrade_head_creates_schema_on_configured_postgres(self) -> None:
|
||||
dsn = os.environ.get("PIPELINE_TEST_DATABASE_DSN")
|
||||
if not dsn:
|
||||
self.skipTest("PIPELINE_TEST_DATABASE_DSN is not set")
|
||||
if not dsn.startswith(("postgresql://", "postgres://")):
|
||||
self.skipTest("Postgres DSN is required for Alembic migration test")
|
||||
if find_spec("alembic") is None:
|
||||
self.skipTest("alembic is not installed")
|
||||
|
||||
env = os.environ.copy()
|
||||
env["DATABASE_URL"] = dsn
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
||||
cwd=REPO_ROOT,
|
||||
env=env,
|
||||
check=True,
|
||||
)
|
||||
|
||||
from src.domain.schema import CORE_TABLES
|
||||
from src.infrastructure.repositories import open_backend_repository
|
||||
|
||||
repository = open_backend_repository(dsn)
|
||||
self.assertTrue(set(CORE_TABLES).issubset(repository.schema.list_tables()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,150 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||
from src.presentation.dependencies import get_repository # noqa: E402
|
||||
from src.presentation.main import app # noqa: E402
|
||||
|
||||
|
||||
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||
|
||||
|
||||
class ResearchManifestPublicApiTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||
self.storage_root = Path(self.tmp_dir.name) / "object-storage"
|
||||
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(self.storage_root)
|
||||
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'research.db'}"
|
||||
self.repository = open_backend_repository(dsn)
|
||||
self.repository.setup()
|
||||
seed_reference_data(self.repository)
|
||||
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||
self.client = TestClient(app)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
app.dependency_overrides.clear()
|
||||
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
|
||||
self.tmp_dir.cleanup()
|
||||
|
||||
def test_approved_plan_research_creates_manifest_with_uploaded_artifacts(self) -> None:
|
||||
article_id = self._create_article_with_approved_plan()
|
||||
|
||||
start_response = self.client.post(
|
||||
f"/api/articles/{article_id}/research/start",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, start_response.status_code, start_response.text)
|
||||
body = start_response.json()
|
||||
self.assertEqual("RESEARCH_RUNNING", body["article"]["status"])
|
||||
self.assertEqual("RESEARCH", body["job"]["job_type"])
|
||||
|
||||
manifest = body["manifest"]
|
||||
self.assertTrue(manifest["s3_prefix"])
|
||||
self.assertEqual(article_id, manifest["article_id"])
|
||||
self.assertEqual(body["job"]["id"], manifest["agent_job_id"])
|
||||
self.assertGreaterEqual(len(manifest["artifacts"]), 2)
|
||||
|
||||
object_keys = [artifact["object_key"] for artifact in manifest["artifacts"]]
|
||||
content_hashes = [artifact["content_hash"] for artifact in manifest["artifacts"]]
|
||||
artifact_types = [artifact["artifact_type"] for artifact in manifest["artifacts"]]
|
||||
source_urls = [artifact["source_url"] for artifact in manifest["artifacts"]]
|
||||
|
||||
self.assertEqual(len(object_keys), len(set(object_keys)))
|
||||
self.assertTrue(all(key.startswith(manifest["s3_prefix"]) for key in object_keys))
|
||||
self.assertTrue(all(content_hashes))
|
||||
self.assertIn("source_section_snapshot", artifact_types)
|
||||
self.assertTrue(all(url.startswith("https://") for url in source_urls))
|
||||
|
||||
for artifact in manifest["artifacts"]:
|
||||
metadata = artifact["metadata"]
|
||||
self.assertTrue(metadata["source_title"])
|
||||
self.assertTrue(metadata["domain"])
|
||||
self.assertTrue(metadata["summary"])
|
||||
self.assertTrue(metadata["retrieved_at"])
|
||||
self.assertTrue(metadata["artifact_link"])
|
||||
self.assertIn("search_path", metadata)
|
||||
self.assertIn("selection_criteria", metadata)
|
||||
self.assertIn("head_metadata", metadata)
|
||||
self.assertIn("server_ip_address", metadata)
|
||||
self.assertIn("whois_owner", metadata)
|
||||
self.assertTrue((self.storage_root / artifact["object_key"]).is_file())
|
||||
|
||||
list_response = self.client.get(
|
||||
f"/api/articles/{article_id}/research",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, list_response.status_code, list_response.text)
|
||||
self.assertEqual([manifest["id"]], [item["id"] for item in list_response.json()["manifests"]])
|
||||
|
||||
rerun_response = self.client.post(
|
||||
f"/api/articles/{article_id}/research/start",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, rerun_response.status_code, rerun_response.text)
|
||||
self.assertNotEqual(manifest["id"], rerun_response.json()["manifest"]["id"])
|
||||
self.assertNotEqual(
|
||||
manifest["s3_prefix"],
|
||||
rerun_response.json()["manifest"]["s3_prefix"],
|
||||
)
|
||||
|
||||
def _create_article_with_approved_plan(self) -> str:
|
||||
site = self.client.get(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
).json()[0]["site"]
|
||||
article = self.client.post(
|
||||
"/api/articles",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"target_site_id": site["id"],
|
||||
"brief_description": "Research auditable AI content operations.",
|
||||
"working_title": "Auditable AI Content Operations",
|
||||
"content_type": "longform_guide",
|
||||
"primary_keyword": "auditable AI content",
|
||||
},
|
||||
).json()["article"]
|
||||
article_id = article["id"]
|
||||
|
||||
questions = self.client.post(
|
||||
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
).json()["questions"]
|
||||
for question in questions:
|
||||
if question["is_required"]:
|
||||
response = self.client.patch(
|
||||
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={"answer": f"Answer for {question['category']}"},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
self.client.post(
|
||||
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
plan = self.client.post(
|
||||
f"/api/articles/{article_id}/plan/generate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
).json()["plan"]
|
||||
approve_response = self.client.post(
|
||||
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, approve_response.status_code, approve_response.text)
|
||||
return article_id
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,170 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def repository_dsn() -> Iterator[str]:
|
||||
configured_dsn = os.environ.get("PIPELINE_TEST_DATABASE_DSN")
|
||||
if configured_dsn:
|
||||
yield configured_dsn
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
yield f"sqlite:///{Path(tmp_dir) / 'pipeline-schema.db'}"
|
||||
|
||||
|
||||
class SchemaStorageContractsIntegrationTest(unittest.TestCase):
|
||||
def test_schema_contains_task_tables_and_operational_indexes(self) -> None:
|
||||
from src.domain.schema import CORE_TABLES
|
||||
from src.infrastructure.repositories import open_backend_repository
|
||||
|
||||
with repository_dsn() as dsn:
|
||||
repository = open_backend_repository(dsn)
|
||||
repository.setup()
|
||||
|
||||
self.assertTrue(set(CORE_TABLES).issubset(repository.schema.list_tables()))
|
||||
self.assertIn(
|
||||
"idx_target_sites_slug",
|
||||
repository.schema.list_indexes("target_sites"),
|
||||
)
|
||||
self.assertIn(
|
||||
"idx_articles_dashboard",
|
||||
repository.schema.list_indexes("articles"),
|
||||
)
|
||||
self.assertIn(
|
||||
"idx_agent_jobs_queue",
|
||||
repository.schema.list_indexes("agent_jobs"),
|
||||
)
|
||||
self.assertIn(
|
||||
"idx_workflow_events_timeline",
|
||||
repository.schema.list_indexes("workflow_events"),
|
||||
)
|
||||
|
||||
def test_required_domain_storage_fields_exist(self) -> None:
|
||||
from src.infrastructure.repositories import open_backend_repository
|
||||
|
||||
with repository_dsn() as dsn:
|
||||
repository = open_backend_repository(dsn)
|
||||
repository.setup()
|
||||
|
||||
self.assertTrue(
|
||||
{"status", "publishing_status"}.issubset(
|
||||
repository.schema.list_columns("articles")
|
||||
)
|
||||
)
|
||||
self.assertIn("status", repository.schema.list_columns("article_drafts"))
|
||||
self.assertTrue(
|
||||
{
|
||||
"created_by",
|
||||
"created_at",
|
||||
"diff",
|
||||
"rollback_target_version_id",
|
||||
"activated_at",
|
||||
"publishing_yaml",
|
||||
"transform_script",
|
||||
}.issubset(repository.schema.list_columns("script_config_versions"))
|
||||
)
|
||||
self.assertTrue(
|
||||
{
|
||||
"parent_job_id",
|
||||
"attempt",
|
||||
"stdout",
|
||||
"stderr",
|
||||
"exit_code",
|
||||
"duration_ms",
|
||||
"workspace_path",
|
||||
"output_files",
|
||||
}.issubset(repository.schema.list_columns("agent_jobs"))
|
||||
)
|
||||
self.assertIn(
|
||||
"category",
|
||||
repository.schema.list_columns("boundary_questions"),
|
||||
)
|
||||
self.assertTrue(
|
||||
{
|
||||
"title_options",
|
||||
"reader_persona",
|
||||
"evidence_needs",
|
||||
"visual_needs",
|
||||
"seo_notes",
|
||||
"source_requirements",
|
||||
"excluded_sources",
|
||||
"tone",
|
||||
"audience",
|
||||
}.issubset(repository.schema.list_columns("article_plans"))
|
||||
)
|
||||
self.assertTrue(
|
||||
{"key_points", "evidence_needs"}.issubset(
|
||||
repository.schema.list_columns("plan_sections")
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
{
|
||||
"s3_prefix",
|
||||
"source_urls",
|
||||
"object_keys",
|
||||
"content_hashes",
|
||||
"artifact_types",
|
||||
"metadata_references",
|
||||
"artifacts",
|
||||
}.issubset(repository.schema.list_columns("research_run_manifests"))
|
||||
)
|
||||
self.assertTrue(
|
||||
{
|
||||
"repository_url",
|
||||
"branch",
|
||||
"commit_sha",
|
||||
"content_bundle_manifest",
|
||||
"status",
|
||||
}.issubset(repository.schema.list_columns("publish_commits"))
|
||||
)
|
||||
|
||||
def test_seed_data_is_idempotent_for_users_site_and_script_version(self) -> None:
|
||||
from src.application.seed_data import seed_reference_data
|
||||
from src.domain.schema import (
|
||||
SEEDED_ADMIN_EMAIL,
|
||||
SEEDED_EDITOR_EMAIL,
|
||||
SEEDED_TARGET_SITE_SLUG,
|
||||
)
|
||||
from src.infrastructure.repositories import open_backend_repository
|
||||
|
||||
with repository_dsn() as dsn:
|
||||
repository = open_backend_repository(dsn)
|
||||
repository.setup()
|
||||
|
||||
seed_reference_data(repository)
|
||||
seed_reference_data(repository)
|
||||
|
||||
site = repository.target_sites.get_by_slug(SEEDED_TARGET_SITE_SLUG)
|
||||
seeded_sites = [
|
||||
candidate
|
||||
for candidate in repository.target_sites.list()
|
||||
if candidate.slug == SEEDED_TARGET_SITE_SLUG
|
||||
]
|
||||
script_versions = repository.script_config_versions.list_for_site(site.id)
|
||||
|
||||
self.assertEqual(1, repository.users.count_by_email(SEEDED_ADMIN_EMAIL))
|
||||
self.assertEqual(1, repository.users.count_by_email(SEEDED_EDITOR_EMAIL))
|
||||
self.assertEqual([site.id], [candidate.id for candidate in seeded_sites])
|
||||
self.assertEqual(1, len(script_versions))
|
||||
self.assertEqual("ACTIVE", script_versions[0]["status"])
|
||||
self.assertEqual(
|
||||
str(site.active_script_config_version_id), str(script_versions[0]["id"])
|
||||
)
|
||||
self.assertIn("repository:", script_versions[0]["publishing_yaml"])
|
||||
self.assertIn("transformArticle", script_versions[0]["transform_script"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from uuid import UUID
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def repository_dsn() -> Iterator[str]:
|
||||
configured = os.environ.get("PIPELINE_TEST_DATABASE_DSN")
|
||||
if configured:
|
||||
yield configured
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
yield f"sqlite:///{Path(directory) / 'pipeline-audit.db'}"
|
||||
|
||||
|
||||
class ScriptConfigAuditTest(unittest.TestCase):
|
||||
def test_rollback_writes_audit_event(self) -> None:
|
||||
with repository_dsn() as dsn:
|
||||
from src.application.auth import get_current_user_by_email
|
||||
from src.application.seed_data import seed_reference_data
|
||||
from src.application.site_config import (
|
||||
create_script_config_version,
|
||||
create_target_site,
|
||||
rollback_script_config_version,
|
||||
)
|
||||
from src.domain.contracts import (
|
||||
PublishingRules,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
TargetSiteConfigCreateRequest,
|
||||
)
|
||||
from src.infrastructure.repositories import open_backend_repository
|
||||
|
||||
repository = open_backend_repository(dsn)
|
||||
repository.setup()
|
||||
seed_reference_data(repository)
|
||||
admin = get_current_user_by_email(repository, "admin@example.com")
|
||||
site = create_target_site(
|
||||
repository,
|
||||
TargetSiteConfigCreateRequest(
|
||||
name="Audit Test Site",
|
||||
slug="audit-site",
|
||||
publishing_type="git_next",
|
||||
default_language="en",
|
||||
brand_voice="Audit-focused publishing profile.",
|
||||
audience="QA operations team.",
|
||||
seo_rules={"primary_keyword_required": True},
|
||||
visual_rules={"hero_style": "technical"},
|
||||
source_rules={"minimum_sources": 1},
|
||||
publishing_rules=PublishingRules(
|
||||
repository_url="git@github.com:example/audit-site.git",
|
||||
production_branch="main",
|
||||
content_format="mdx",
|
||||
content_path_template="content/articles/{slug}.mdx",
|
||||
asset_path_template="public/articles/{slug}/{filename}",
|
||||
frontmatter_mapping={"title": "title"},
|
||||
dry_run_renderer="next-mdx",
|
||||
),
|
||||
active_script_config_version_id=None,
|
||||
),
|
||||
).site
|
||||
|
||||
initial = create_script_config_version(
|
||||
repository,
|
||||
site_id=site.id,
|
||||
current_user=admin,
|
||||
request=self._script_version_payload(summary="initial", activate=True),
|
||||
).version
|
||||
candidate = create_script_config_version(
|
||||
repository,
|
||||
site_id=site.id,
|
||||
current_user=admin,
|
||||
request=self._script_version_payload(
|
||||
summary="candidate",
|
||||
activate=True,
|
||||
rollback_target_version_id=initial.id,
|
||||
),
|
||||
).version
|
||||
|
||||
rolled_back = rollback_script_config_version(
|
||||
repository,
|
||||
site_id=site.id,
|
||||
version_id=initial.id,
|
||||
current_user=admin,
|
||||
)
|
||||
self.assertEqual(str(initial.id), str(rolled_back.version.id))
|
||||
|
||||
rollback_events = [
|
||||
event
|
||||
for event in repository.script_config_version_events.list_for_site(site.id)
|
||||
if event["event_type"] == "SCRIPT_CONFIG_VERSION_ROLLBACK"
|
||||
]
|
||||
self.assertEqual(1, len(rollback_events))
|
||||
rollback_event = rollback_events[0]
|
||||
self.assertEqual(str(site.id), str(rollback_event["target_site_id"]))
|
||||
self.assertEqual(str(initial.id), str(rollback_event["version_id"]))
|
||||
self.assertEqual(str(admin.id), str(rollback_event["actor_user_id"]))
|
||||
self.assertEqual(
|
||||
str(candidate.id),
|
||||
rollback_event["payload"]["rollback_target_version_id"],
|
||||
)
|
||||
|
||||
active_site = repository.target_sites.get_by_id(site.id)
|
||||
self.assertEqual(initial.id, active_site.active_script_config_version_id)
|
||||
|
||||
def _script_version_payload(
|
||||
self,
|
||||
summary: str,
|
||||
activate: bool = False,
|
||||
rollback_target_version_id: UUID | None = None,
|
||||
) -> ScriptConfigVersionCreateRequest:
|
||||
payload = ScriptConfigVersionCreateRequest(
|
||||
publishing_yaml="target:\n repo: audit\n",
|
||||
transform_script="export function transformArticle(article) { return article; }",
|
||||
diff={"summary": summary},
|
||||
activate=activate,
|
||||
)
|
||||
if rollback_target_version_id is not None:
|
||||
payload.rollback_target_version_id = rollback_target_version_id
|
||||
return payload
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
|
||||
SEEDED_SITE_SLUG = "b2b_saas_blog"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def repository_dsn() -> Iterator[str]:
|
||||
configured_dsn = os.environ.get("PIPELINE_TEST_DATABASE_DSN")
|
||||
if configured_dsn:
|
||||
yield configured_dsn
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
yield f"sqlite:///{Path(tmp_dir) / 'pipeline-seed-red.db'}"
|
||||
|
||||
|
||||
class SeededTargetSiteLookupIntegrationTest(unittest.TestCase):
|
||||
def test_seeded_git_next_target_site_lookup_by_slug_is_idempotent(self) -> None:
|
||||
from src.application.seed_data import seed_reference_data
|
||||
from src.infrastructure.repositories import open_backend_repository
|
||||
|
||||
with repository_dsn() as dsn:
|
||||
repository = open_backend_repository(dsn)
|
||||
repository.setup()
|
||||
|
||||
seed_reference_data(repository)
|
||||
seed_reference_data(repository)
|
||||
|
||||
site = repository.target_sites.get_by_slug(SEEDED_SITE_SLUG)
|
||||
seeded_sites = [
|
||||
candidate
|
||||
for candidate in repository.target_sites.list()
|
||||
if candidate.slug == SEEDED_SITE_SLUG
|
||||
]
|
||||
|
||||
from src.domain.contracts import TargetSiteConfig
|
||||
|
||||
self.assertIsInstance(site, TargetSiteConfig)
|
||||
self.assertEqual([site.id], [candidate.id for candidate in seeded_sites])
|
||||
self.assertEqual("B2B SaaS Blog", site.name)
|
||||
self.assertEqual("git_next", site.publishing_type)
|
||||
self.assertEqual("en", site.default_language)
|
||||
self.assertEqual(
|
||||
"git@github.com:example/site.git",
|
||||
site.publishing_rules.repository_url,
|
||||
)
|
||||
self.assertEqual("main", site.publishing_rules.production_branch)
|
||||
self.assertEqual("mdx", site.publishing_rules.content_format)
|
||||
self.assertEqual(
|
||||
"content/articles/{slug}.mdx",
|
||||
site.publishing_rules.content_path_template,
|
||||
)
|
||||
self.assertEqual(
|
||||
"public/articles/{slug}/{filename}",
|
||||
site.publishing_rules.asset_path_template,
|
||||
)
|
||||
self.assertIsNotNone(site.active_script_config_version_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,12 +1,19 @@
|
||||
FROM node:22-alpine
|
||||
FROM node:22-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY apps/frontend/package.json ./package.json
|
||||
RUN npm install
|
||||
RUN corepack enable
|
||||
|
||||
COPY apps/frontend ./
|
||||
COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/frontend/package.json ./apps/frontend/package.json
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY apps/frontend ./apps/frontend
|
||||
COPY packages/shared ./packages/shared
|
||||
|
||||
WORKDIR /app/apps/frontend
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--hostname", "0.0.0.0"]
|
||||
CMD ["pnpm", "exec", "next", "dev", "--hostname", "0.0.0.0"]
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
"start": "next start",
|
||||
"test:roles": "node tests/role-navigation.test.mjs",
|
||||
"test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs && node tests/agent_jobs.model.test.mjs && node tests/boundary_questions.model.test.mjs && node tests/plan_review.model.test.mjs && node tests/research_manifest.model.test.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pipeline/shared": "workspace:*",
|
||||
"next": "16.2.6",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
@@ -17,6 +21,10 @@
|
||||
"@types/react-dom": "19.0.2",
|
||||
"typescript": "5.7.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-linux-arm64-gnu": "16.2.6",
|
||||
"@next/swc-linux-x64-gnu": "16.2.6"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss": "8.5.14"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import AdminJobsPage from "@/pages/admin-jobs";
|
||||
|
||||
export default AdminJobsPage;
|
||||
@@ -0,0 +1,3 @@
|
||||
import AdminScriptsPage from "@/pages/admin-scripts";
|
||||
|
||||
export { AdminScriptsPage as default };
|
||||
@@ -0,0 +1,3 @@
|
||||
import AdminSitesPage from "@/pages/admin-sites";
|
||||
|
||||
export default AdminSitesPage;
|
||||
@@ -0,0 +1,14 @@
|
||||
import ArticleBoundaryQuestionsPage from "@/pages/article-boundary-questions";
|
||||
|
||||
type DynamicArticleBoundaryQuestionsPageProps = {
|
||||
params: {
|
||||
articleId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function DynamicArticleBoundaryQuestionsPage({
|
||||
params,
|
||||
}: DynamicArticleBoundaryQuestionsPageProps) {
|
||||
const { articleId } = params;
|
||||
return <ArticleBoundaryQuestionsPage articleId={articleId} />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import ArticleDetailPage from "@/pages/article-detail";
|
||||
|
||||
type DynamicArticleDetailPageProps = {
|
||||
params: {
|
||||
articleId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function DynamicArticleDetailPage({
|
||||
params,
|
||||
}: DynamicArticleDetailPageProps) {
|
||||
const { articleId } = params;
|
||||
return <ArticleDetailPage articleId={articleId} />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import ArticlePlansPage from "@/pages/article-plans";
|
||||
|
||||
type DynamicArticlePlansPageProps = {
|
||||
params: {
|
||||
articleId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function DynamicArticlePlansPage({
|
||||
params,
|
||||
}: DynamicArticlePlansPageProps) {
|
||||
const { articleId } = params;
|
||||
return <ArticlePlansPage articleId={articleId} />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import ArticleResearchPage from "@/pages/article-research";
|
||||
|
||||
type DynamicArticleResearchPageProps = {
|
||||
params: {
|
||||
articleId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function DynamicArticleResearchPage({
|
||||
params,
|
||||
}: DynamicArticleResearchPageProps) {
|
||||
const { articleId } = params;
|
||||
return <ArticleResearchPage articleId={articleId} />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import NewArticlePage from "@/pages/article-new";
|
||||
|
||||
export { NewArticlePage as default };
|
||||
@@ -19,3 +19,338 @@ main {
|
||||
min-height: 100vh;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
max-width: 920px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.pageHeader h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 32px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.pageHeader p {
|
||||
margin: 0;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.roleBadge {
|
||||
display: inline-flex;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
border: 1px solid #b7c0cc;
|
||||
border-radius: 6px;
|
||||
padding: 0 12px;
|
||||
background: #ffffff;
|
||||
color: #344054;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.roleNavigation {
|
||||
display: grid;
|
||||
max-width: 920px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.roleNavigation a {
|
||||
display: flex;
|
||||
min-height: 52px;
|
||||
align-items: center;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 8px;
|
||||
padding: 0 16px;
|
||||
background: #ffffff;
|
||||
color: #1d2733;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.panel {
|
||||
max-width: 980px;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 16px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
border-bottom: 1px solid #d0d5dd;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: #eef2f6;
|
||||
}
|
||||
|
||||
.table a {
|
||||
font-weight: 600;
|
||||
color: #1f4b99;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.table a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
margin-top: 8px;
|
||||
padding: 16px;
|
||||
border: 1px dashed #b7c0cc;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.articleForm {
|
||||
max-width: 620px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.articleForm label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.articleForm input,
|
||||
.articleForm select,
|
||||
.articleForm textarea {
|
||||
width: 100%;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d0d5dd;
|
||||
padding: 10px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.articleForm textarea {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.codeArea {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
min-height: 120px;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
margin: 0;
|
||||
color: #b42318;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.formSuccess {
|
||||
margin: 0;
|
||||
color: #027a48;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.formError {
|
||||
margin: 4px 0 0;
|
||||
color: #b42318;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inlineLinks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.inlineLinks a {
|
||||
text-decoration: none;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
background: #ffffff;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.inlineLinks .activeLink {
|
||||
border-color: #1f4b99;
|
||||
color: #1f4b99;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table button {
|
||||
border-radius: 6px;
|
||||
border: 1px solid #1d2733;
|
||||
background: #ffffff;
|
||||
color: #1d2733;
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.tableAction {
|
||||
background: #eceff5 !important;
|
||||
}
|
||||
|
||||
.formActions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.formActions button {
|
||||
border-radius: 6px;
|
||||
border: 1px solid #1d2733;
|
||||
background: #1d2733;
|
||||
color: #ffffff;
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sectionHeader h2,
|
||||
.sectionHeader p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sectionHeader button {
|
||||
border-radius: 6px;
|
||||
border: 1px solid #1d2733;
|
||||
background: #ffffff;
|
||||
color: #1d2733;
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.boundaryQuestions {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.questionList {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.questionItem {
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.questionHeader {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.questionHeader p {
|
||||
margin: 6px 0 10px;
|
||||
color: #475467;
|
||||
}
|
||||
|
||||
.questionItem textarea {
|
||||
width: 100%;
|
||||
min-height: 96px;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.requiredBadge,
|
||||
.optionalBadge {
|
||||
display: inline-flex;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.requiredBadge {
|
||||
border: 1px solid #b42318;
|
||||
color: #b42318;
|
||||
background: #fff4f2;
|
||||
}
|
||||
|
||||
.optionalBadge {
|
||||
border: 1px solid #667085;
|
||||
color: #344054;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.detailGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detailGrid div {
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.detailGrid dt {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: #667085;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.detailGrid dd {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
max-width: 980px;
|
||||
}
|
||||
|
||||
.timeline li {
|
||||
border-bottom: 1px solid #d0d5dd;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
main {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main>
|
||||
<h1>AI Content Pipeline</h1>
|
||||
<p>Dockerized monorepo foundation is running.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
import DashboardPage from "@/pages/dashboard";
|
||||
|
||||
export { DashboardPage as default };
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type {
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionSummary,
|
||||
} from "@pipeline/shared";
|
||||
|
||||
export type AdminScriptVersionRow = {
|
||||
id: string;
|
||||
version: number;
|
||||
status: string;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
activatedAt: string | null;
|
||||
publishingYamlHash: string;
|
||||
transformScriptHash: string;
|
||||
isActive: boolean;
|
||||
rollbackTargetVersionId: string | null;
|
||||
diff: string;
|
||||
};
|
||||
|
||||
export type NewAdminScriptVersionDraft = {
|
||||
publishingYaml: string;
|
||||
transformScript: string;
|
||||
diff: string;
|
||||
activate: boolean;
|
||||
rollbackTargetVersionId: string;
|
||||
};
|
||||
|
||||
export type DraftValidationErrors = Partial<Record<keyof NewAdminScriptVersionDraft, string>>;
|
||||
|
||||
const DEFAULT_DIFF = '{\n "summary": "manual publish pipeline change"\n}';
|
||||
const DEFAULT_PUBLISHING_YAML = "target: site\nrepository:\n branch: main\n";
|
||||
const DEFAULT_TRANSFORM_SCRIPT = "export function transformArticle(article) {\n return article;\n}\n";
|
||||
|
||||
export function buildAdminScriptVersionRows(
|
||||
versions: readonly ScriptConfigVersionSummary[],
|
||||
): AdminScriptVersionRow[] {
|
||||
return versions.map((version) => ({
|
||||
id: version.id,
|
||||
version: version.version,
|
||||
status: version.status,
|
||||
createdBy: version.created_by,
|
||||
createdAt: version.created_at,
|
||||
activatedAt: version.activated_at ?? null,
|
||||
publishingYamlHash: version.publishing_yaml_hash,
|
||||
transformScriptHash: version.transform_script_hash,
|
||||
isActive: version.status === "ACTIVE",
|
||||
rollbackTargetVersionId: version.rollback_target_version_id ?? null,
|
||||
diff: JSON.stringify(version.diff),
|
||||
}));
|
||||
}
|
||||
|
||||
export function defaultScriptVersionDraft(): NewAdminScriptVersionDraft {
|
||||
return {
|
||||
publishingYaml: DEFAULT_PUBLISHING_YAML,
|
||||
transformScript: DEFAULT_TRANSFORM_SCRIPT,
|
||||
diff: DEFAULT_DIFF,
|
||||
activate: false,
|
||||
rollbackTargetVersionId: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCreateScriptVersionPayload(
|
||||
draft: NewAdminScriptVersionDraft,
|
||||
): ScriptConfigVersionCreateRequest {
|
||||
const parsedDiff = parseJsonObject(draft.diff) ?? {};
|
||||
|
||||
return {
|
||||
publishing_yaml: draft.publishingYaml,
|
||||
transform_script: draft.transformScript,
|
||||
diff: parsedDiff,
|
||||
activate: draft.activate,
|
||||
rollback_target_version_id: draft.rollbackTargetVersionId
|
||||
? (draft.rollbackTargetVersionId as string)
|
||||
: null,
|
||||
version: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseJsonObject(value: string): Record<string, unknown> | null {
|
||||
if (!value.trim()) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
!Array.isArray(parsed) &&
|
||||
parsed !== null
|
||||
) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
|
||||
import type { ScriptConfigVersionSummary, TargetSiteConfig } from "@pipeline/shared";
|
||||
|
||||
import { ApiError, DEMO_ADMIN_EMAIL, activateScriptConfigVersion, createScriptConfigVersion, fetchScriptConfigVersions, rollbackScriptConfigVersion } from "@/shared/pipeline-api";
|
||||
import {
|
||||
buildAdminScriptVersionRows,
|
||||
buildCreateScriptVersionPayload,
|
||||
defaultScriptVersionDraft,
|
||||
parseJsonObject,
|
||||
type DraftValidationErrors,
|
||||
type NewAdminScriptVersionDraft,
|
||||
} from "./model";
|
||||
|
||||
type AdminScriptVersionsPanelProps = {
|
||||
site: TargetSiteConfig;
|
||||
initialVersions: readonly ScriptConfigVersionSummary[];
|
||||
};
|
||||
|
||||
function parseOptionalError(value: string): string | null {
|
||||
if (value.trim().length === 0) {
|
||||
return "Required value";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function AdminScriptVersionsPanel({
|
||||
site,
|
||||
initialVersions,
|
||||
}: AdminScriptVersionsPanelProps) {
|
||||
const [versions, setVersions] = useState<readonly ScriptConfigVersionSummary[]>(
|
||||
initialVersions,
|
||||
);
|
||||
const [draft, setDraft] = useState<NewAdminScriptVersionDraft>(
|
||||
defaultScriptVersionDraft(),
|
||||
);
|
||||
const [errors, setErrors] = useState<DraftValidationErrors>({});
|
||||
const [formError, setFormError] = useState("");
|
||||
const [actionMessage, setActionMessage] = useState("");
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
buildAdminScriptVersionRows(versions),
|
||||
[versions],
|
||||
);
|
||||
|
||||
const hasVersions = rows.length > 0;
|
||||
const canSubmit = !isSubmitting;
|
||||
const rollbackCandidates = rows.filter((row) => row.id !== site.active_script_config_version_id);
|
||||
|
||||
async function refreshVersions() {
|
||||
const latest = await fetchScriptConfigVersions(
|
||||
site.id,
|
||||
DEMO_ADMIN_EMAIL,
|
||||
);
|
||||
setVersions(latest.versions);
|
||||
}
|
||||
|
||||
function updateField<K extends keyof NewAdminScriptVersionDraft>(
|
||||
field: K,
|
||||
value: NewAdminScriptVersionDraft[K],
|
||||
) {
|
||||
setDraft((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
setFormError("");
|
||||
setActionMessage("");
|
||||
}
|
||||
|
||||
async function handleCreateVersion(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const nextErrors: DraftValidationErrors = {};
|
||||
|
||||
const publishingYamlError = parseOptionalError(draft.publishingYaml);
|
||||
const transformScriptError = parseOptionalError(draft.transformScript);
|
||||
const diffObject = parseJsonObject(draft.diff);
|
||||
|
||||
if (publishingYamlError) {
|
||||
nextErrors.publishingYaml = publishingYamlError;
|
||||
}
|
||||
if (transformScriptError) {
|
||||
nextErrors.transformScript = transformScriptError;
|
||||
}
|
||||
if (diffObject === null) {
|
||||
nextErrors.diff = "Invalid JSON object";
|
||||
}
|
||||
|
||||
if (draft.rollbackTargetVersionId) {
|
||||
const validRollback = rows.some((row) => row.id === draft.rollbackTargetVersionId);
|
||||
if (!validRollback) {
|
||||
nextErrors.rollbackTargetVersionId = "Select a valid version ID";
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
setErrors(nextErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrors({});
|
||||
clearMessages();
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const payload = buildCreateScriptVersionPayload(draft);
|
||||
await createScriptConfigVersion(site.id, payload, DEMO_ADMIN_EMAIL);
|
||||
setActionMessage("New version created");
|
||||
await refreshVersions();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setFormError(error.message || "Request failed");
|
||||
} else {
|
||||
setFormError("Unable to create publishing version");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleActivate(versionId: string) {
|
||||
setSubmitting(true);
|
||||
clearMessages();
|
||||
try {
|
||||
await activateScriptConfigVersion(site.id, versionId, DEMO_ADMIN_EMAIL);
|
||||
await refreshVersions();
|
||||
setActionMessage("Version activated");
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setFormError(error.message || "Request failed");
|
||||
} else {
|
||||
setFormError("Unable to activate version");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRollback(versionId: string) {
|
||||
setSubmitting(true);
|
||||
clearMessages();
|
||||
try {
|
||||
await rollbackScriptConfigVersion(site.id, versionId, DEMO_ADMIN_EMAIL);
|
||||
await refreshVersions();
|
||||
setActionMessage("Rollback applied");
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setFormError(error.message || "Request failed");
|
||||
} else {
|
||||
setFormError("Unable to rollback version");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>{site.name} script versions</h2>
|
||||
{formError ? <p className="formError">Error: {formError}</p> : null}
|
||||
{actionMessage ? <p className="formSuccess">{actionMessage}</p> : null}
|
||||
|
||||
<h3>Create new version</h3>
|
||||
<form className="articleForm" onSubmit={handleCreateVersion}>
|
||||
<label htmlFor="publishingYaml">Publishing YAML</label>
|
||||
<textarea
|
||||
id="publishingYaml"
|
||||
className="codeArea"
|
||||
value={draft.publishingYaml}
|
||||
onChange={(event) => updateField("publishingYaml", event.target.value)}
|
||||
/>
|
||||
{errors.publishingYaml ? <p className="fieldError">{errors.publishingYaml}</p> : null}
|
||||
|
||||
<label htmlFor="transformScript">Transform script</label>
|
||||
<textarea
|
||||
id="transformScript"
|
||||
className="codeArea"
|
||||
value={draft.transformScript}
|
||||
onChange={(event) => updateField("transformScript", event.target.value)}
|
||||
/>
|
||||
{errors.transformScript ? (
|
||||
<p className="fieldError">{errors.transformScript}</p>
|
||||
) : null}
|
||||
|
||||
<label htmlFor="diff">Diff payload (JSON)</label>
|
||||
<textarea
|
||||
id="diff"
|
||||
className="codeArea"
|
||||
value={draft.diff}
|
||||
onChange={(event) => updateField("diff", event.target.value)}
|
||||
/>
|
||||
{errors.diff ? <p className="fieldError">{errors.diff}</p> : null}
|
||||
|
||||
<label htmlFor="activate">
|
||||
<input
|
||||
id="activate"
|
||||
type="checkbox"
|
||||
checked={draft.activate}
|
||||
onChange={(event) => updateField("activate", event.target.checked)}
|
||||
/>
|
||||
Activate on create
|
||||
</label>
|
||||
|
||||
<label htmlFor="rollbackTargetVersionId">Rollback target version (optional)</label>
|
||||
<select
|
||||
id="rollbackTargetVersionId"
|
||||
value={draft.rollbackTargetVersionId}
|
||||
onChange={(event) => updateField("rollbackTargetVersionId", event.target.value)}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{rollbackCandidates.map((candidate) => (
|
||||
<option key={candidate.id} value={candidate.id}>
|
||||
v{candidate.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.rollbackTargetVersionId ? (
|
||||
<p className="fieldError">{errors.rollbackTargetVersionId}</p>
|
||||
) : null}
|
||||
|
||||
<div className="formActions">
|
||||
<button type="submit" disabled={!canSubmit}>
|
||||
{isSubmitting ? "Saving..." : "Create version"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h3>Version history</h3>
|
||||
{!hasVersions ? (
|
||||
<p className="emptyState">No versions yet.</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Version</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Activated</th>
|
||||
<th>Author</th>
|
||||
<th>Hashes</th>
|
||||
<th>Rollback target</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((version) => (
|
||||
<tr key={version.id}>
|
||||
<td>
|
||||
{version.version} {version.isActive ? "(active)" : null}
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<strong>diff</strong>: {version.diff}
|
||||
</div>
|
||||
</td>
|
||||
<td>{version.status}</td>
|
||||
<td>{new Date(version.createdAt).toLocaleString()}</td>
|
||||
<td>
|
||||
{version.activatedAt
|
||||
? new Date(version.activatedAt).toLocaleString()
|
||||
: "—"}
|
||||
</td>
|
||||
<td>{version.createdBy}</td>
|
||||
<td>
|
||||
<div>YAML: {version.publishingYamlHash.slice(0, 8)}</div>
|
||||
<div>Script: {version.transformScriptHash.slice(0, 8)}</div>
|
||||
</td>
|
||||
<td>{version.rollbackTargetVersionId ?? "—"}</td>
|
||||
<td>
|
||||
{!version.isActive ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleActivate(version.id)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Activate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRollback(version.id)}
|
||||
disabled={isSubmitting}
|
||||
className="tableAction"
|
||||
>
|
||||
Rollback
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span>Current</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { TargetSiteConfigCreateRequest } from "@pipeline/shared";
|
||||
|
||||
import { ApiError, createTargetSite, DEMO_ADMIN_EMAIL } from "@/shared/pipeline-api";
|
||||
|
||||
type AdminSiteDraft = {
|
||||
name: string;
|
||||
slug: string;
|
||||
defaultLanguage: string;
|
||||
brandVoice: string;
|
||||
audience: string;
|
||||
publishingType: string;
|
||||
repositoryUrl: string;
|
||||
productionBranch: string;
|
||||
contentFormat: string;
|
||||
contentPathTemplate: string;
|
||||
assetPathTemplate: string;
|
||||
dryRunRenderer: string;
|
||||
frontmatterMapping: string;
|
||||
seoRules: string;
|
||||
visualRules: string;
|
||||
sourceRules: string;
|
||||
};
|
||||
|
||||
type ValidationErrors = Partial<Record<keyof AdminSiteDraft, string>>;
|
||||
|
||||
const defaultDraft = (): AdminSiteDraft => ({
|
||||
name: "",
|
||||
slug: "",
|
||||
defaultLanguage: "en",
|
||||
brandVoice: "Practical and helpful editorial guidance.",
|
||||
audience: "Content and marketing teams.",
|
||||
publishingType: "git_next",
|
||||
repositoryUrl: "",
|
||||
productionBranch: "main",
|
||||
contentFormat: "mdx",
|
||||
contentPathTemplate: "content/articles/{slug}.mdx",
|
||||
assetPathTemplate: "public/articles/{slug}/{filename}",
|
||||
dryRunRenderer: "next-mdx",
|
||||
frontmatterMapping: JSON.stringify(
|
||||
{
|
||||
title: "title",
|
||||
description: "description",
|
||||
publishedAt: "published_at",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
seoRules: JSON.stringify({}, null, 2),
|
||||
visualRules: JSON.stringify({}, null, 2),
|
||||
sourceRules: JSON.stringify({}, null, 2),
|
||||
});
|
||||
|
||||
function parseJsonField(value: string): Record<string, unknown> | null {
|
||||
if (!value.trim()) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function AdminSiteCreateForm() {
|
||||
const router = useRouter();
|
||||
const [values, setValues] = useState<AdminSiteDraft>(defaultDraft());
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
const [formError, setFormError] = useState("");
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() =>
|
||||
!isSubmitting &&
|
||||
values.name.trim() !== "" &&
|
||||
values.slug.trim() !== "" &&
|
||||
values.repositoryUrl.trim() !== "" &&
|
||||
values.brandVoice.trim() !== "" &&
|
||||
values.audience.trim() !== "",
|
||||
[isSubmitting, values],
|
||||
);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
const nextErrors: ValidationErrors = {};
|
||||
const parsedFrontmatter = parseJsonField(values.frontmatterMapping);
|
||||
const parsedSeo = parseJsonField(values.seoRules);
|
||||
const parsedVisual = parseJsonField(values.visualRules);
|
||||
const parsedSource = parseJsonField(values.sourceRules);
|
||||
|
||||
if (parsedFrontmatter === null) {
|
||||
nextErrors.frontmatterMapping = "Invalid JSON object";
|
||||
}
|
||||
if (parsedSeo === null) {
|
||||
nextErrors.seoRules = "Invalid JSON object";
|
||||
}
|
||||
if (parsedVisual === null) {
|
||||
nextErrors.visualRules = "Invalid JSON object";
|
||||
}
|
||||
if (parsedSource === null) {
|
||||
nextErrors.sourceRules = "Invalid JSON object";
|
||||
}
|
||||
if (!values.slug.trim()) {
|
||||
nextErrors.slug = "Slug is required";
|
||||
}
|
||||
if (!values.name.trim()) {
|
||||
nextErrors.name = "Name is required";
|
||||
}
|
||||
if (!values.repositoryUrl.trim()) {
|
||||
nextErrors.repositoryUrl = "Repository URL is required";
|
||||
}
|
||||
if (!values.brandVoice.trim()) {
|
||||
nextErrors.brandVoice = "Brand voice is required";
|
||||
}
|
||||
if (!values.audience.trim()) {
|
||||
nextErrors.audience = "Audience is required";
|
||||
}
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
setErrors(nextErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrors({});
|
||||
setFormError("");
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload: TargetSiteConfigCreateRequest = {
|
||||
name: values.name,
|
||||
slug: values.slug,
|
||||
publishing_type: values.publishingType,
|
||||
default_language: values.defaultLanguage,
|
||||
brand_voice: values.brandVoice,
|
||||
audience: values.audience,
|
||||
seo_rules: parsedSeo ?? {},
|
||||
visual_rules: parsedVisual ?? {},
|
||||
source_rules: parsedSource ?? {},
|
||||
publishing_rules: {
|
||||
repository_url: values.repositoryUrl,
|
||||
production_branch: values.productionBranch,
|
||||
content_format: values.contentFormat,
|
||||
content_path_template: values.contentPathTemplate,
|
||||
asset_path_template: values.assetPathTemplate,
|
||||
frontmatter_mapping: parsedFrontmatter ?? {},
|
||||
dry_run_renderer: values.dryRunRenderer || null,
|
||||
},
|
||||
};
|
||||
|
||||
await createTargetSite(payload, DEMO_ADMIN_EMAIL);
|
||||
router.push("/admin/sites");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setFormError(error.message || "Request failed");
|
||||
} else {
|
||||
setFormError("Unable to create site config");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateField<K extends keyof AdminSiteDraft>(
|
||||
field: K,
|
||||
value: AdminSiteDraft[K],
|
||||
) {
|
||||
setValues((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="articleForm" onSubmit={handleSubmit}>
|
||||
<h2>Create target site config</h2>
|
||||
{formError ? <p className="formError">Error: {formError}</p> : null}
|
||||
<label htmlFor="name">Name</label>
|
||||
<input
|
||||
id="name"
|
||||
value={values.name}
|
||||
onChange={(event) => updateField("name", event.target.value)}
|
||||
placeholder="Editorial blog"
|
||||
/>
|
||||
{errors.name ? <p className="fieldError">{errors.name}</p> : null}
|
||||
|
||||
<label htmlFor="slug">Slug</label>
|
||||
<input
|
||||
id="slug"
|
||||
value={values.slug}
|
||||
onChange={(event) => updateField("slug", event.target.value)}
|
||||
placeholder="editorial-blog"
|
||||
/>
|
||||
{errors.slug ? <p className="fieldError">{errors.slug}</p> : null}
|
||||
|
||||
<label htmlFor="defaultLanguage">Default language</label>
|
||||
<input
|
||||
id="defaultLanguage"
|
||||
value={values.defaultLanguage}
|
||||
onChange={(event) => updateField("defaultLanguage", event.target.value)}
|
||||
/>
|
||||
|
||||
<label htmlFor="publishingType">Publishing type</label>
|
||||
<input
|
||||
id="publishingType"
|
||||
value={values.publishingType}
|
||||
onChange={(event) => updateField("publishingType", event.target.value)}
|
||||
/>
|
||||
|
||||
<label htmlFor="brandVoice">Brand voice</label>
|
||||
<input
|
||||
id="brandVoice"
|
||||
value={values.brandVoice}
|
||||
onChange={(event) => updateField("brandVoice", event.target.value)}
|
||||
placeholder="Confident and practical"
|
||||
/>
|
||||
{errors.brandVoice ? <p className="fieldError">{errors.brandVoice}</p> : null}
|
||||
|
||||
<label htmlFor="audience">Audience</label>
|
||||
<input
|
||||
id="audience"
|
||||
value={values.audience}
|
||||
onChange={(event) => updateField("audience", event.target.value)}
|
||||
placeholder="Marketing and editorial teams"
|
||||
/>
|
||||
{errors.audience ? <p className="fieldError">{errors.audience}</p> : null}
|
||||
|
||||
<label htmlFor="repositoryUrl">Repository URL</label>
|
||||
<input
|
||||
id="repositoryUrl"
|
||||
value={values.repositoryUrl}
|
||||
onChange={(event) => updateField("repositoryUrl", event.target.value)}
|
||||
placeholder="git@github.com:org/repo.git"
|
||||
/>
|
||||
{errors.repositoryUrl ? <p className="fieldError">{errors.repositoryUrl}</p> : null}
|
||||
|
||||
<label htmlFor="productionBranch">Production branch</label>
|
||||
<input
|
||||
id="productionBranch"
|
||||
value={values.productionBranch}
|
||||
onChange={(event) => updateField("productionBranch", event.target.value)}
|
||||
placeholder="main"
|
||||
/>
|
||||
|
||||
<label htmlFor="contentFormat">Content format</label>
|
||||
<input
|
||||
id="contentFormat"
|
||||
value={values.contentFormat}
|
||||
onChange={(event) => updateField("contentFormat", event.target.value)}
|
||||
placeholder="mdx"
|
||||
/>
|
||||
|
||||
<label htmlFor="contentPathTemplate">Content path template</label>
|
||||
<input
|
||||
id="contentPathTemplate"
|
||||
value={values.contentPathTemplate}
|
||||
onChange={(event) => updateField("contentPathTemplate", event.target.value)}
|
||||
placeholder="content/articles/{slug}.mdx"
|
||||
/>
|
||||
|
||||
<label htmlFor="assetPathTemplate">Asset path template</label>
|
||||
<input
|
||||
id="assetPathTemplate"
|
||||
value={values.assetPathTemplate}
|
||||
onChange={(event) => updateField("assetPathTemplate", event.target.value)}
|
||||
placeholder="public/articles/{slug}/{filename}"
|
||||
/>
|
||||
|
||||
<label htmlFor="dryRunRenderer">Dry-run renderer</label>
|
||||
<input
|
||||
id="dryRunRenderer"
|
||||
value={values.dryRunRenderer}
|
||||
onChange={(event) => updateField("dryRunRenderer", event.target.value)}
|
||||
/>
|
||||
|
||||
<label htmlFor="frontmatterMapping">Frontmatter mapping (JSON)</label>
|
||||
<textarea
|
||||
id="frontmatterMapping"
|
||||
className="codeArea"
|
||||
value={values.frontmatterMapping}
|
||||
onChange={(event) => updateField("frontmatterMapping", event.target.value)}
|
||||
/>
|
||||
{errors.frontmatterMapping ? (
|
||||
<p className="fieldError">{errors.frontmatterMapping}</p>
|
||||
) : null}
|
||||
|
||||
<label htmlFor="seoRules">SEO rules (JSON)</label>
|
||||
<textarea
|
||||
id="seoRules"
|
||||
className="codeArea"
|
||||
value={values.seoRules}
|
||||
onChange={(event) => updateField("seoRules", event.target.value)}
|
||||
/>
|
||||
{errors.seoRules ? <p className="fieldError">{errors.seoRules}</p> : null}
|
||||
|
||||
<label htmlFor="visualRules">Visual rules (JSON)</label>
|
||||
<textarea
|
||||
id="visualRules"
|
||||
className="codeArea"
|
||||
value={values.visualRules}
|
||||
onChange={(event) => updateField("visualRules", event.target.value)}
|
||||
/>
|
||||
{errors.visualRules ? <p className="fieldError">{errors.visualRules}</p> : null}
|
||||
|
||||
<label htmlFor="sourceRules">Source rules (JSON)</label>
|
||||
<textarea
|
||||
id="sourceRules"
|
||||
className="codeArea"
|
||||
value={values.sourceRules}
|
||||
onChange={(event) => updateField("sourceRules", event.target.value)}
|
||||
/>
|
||||
{errors.sourceRules ? <p className="fieldError">{errors.sourceRules}</p> : null}
|
||||
|
||||
<div className="formActions">
|
||||
<button type="submit" disabled={!canSubmit}>
|
||||
{isSubmitting ? "Creating..." : "Create site"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { TargetSiteConfig } from "@pipeline/shared";
|
||||
|
||||
export type AdminSiteRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
publishingRepository: string;
|
||||
productionBranch: string;
|
||||
activeVersionId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function buildAdminSiteRows(
|
||||
sites: readonly TargetSiteConfig[],
|
||||
): AdminSiteRow[] {
|
||||
return sites.map((site) => ({
|
||||
id: site.id,
|
||||
name: site.name,
|
||||
slug: site.slug,
|
||||
publishingRepository: site.publishing_rules.repository_url,
|
||||
productionBranch: site.publishing_rules.production_branch,
|
||||
activeVersionId: site.active_script_config_version_id ?? null,
|
||||
createdAt: site.created_at,
|
||||
updatedAt: site.updated_at,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import type { AdminSiteRow } from "./model";
|
||||
|
||||
type AdminSiteConfigTableProps = {
|
||||
rows: readonly AdminSiteRow[];
|
||||
};
|
||||
|
||||
export function AdminSiteConfigTable({ rows }: AdminSiteConfigTableProps) {
|
||||
if (rows.length === 0) {
|
||||
return <p className="emptyState">No target sites yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Slug</th>
|
||||
<th>Repository</th>
|
||||
<th>Branch</th>
|
||||
<th>Active config version</th>
|
||||
<th>Revisions</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{row.name}</td>
|
||||
<td>{row.slug}</td>
|
||||
<td>{row.publishingRepository}</td>
|
||||
<td>{row.productionBranch}</td>
|
||||
<td>{row.activeVersionId ?? "none"}</td>
|
||||
<td>
|
||||
<Link href={`/admin/scripts?siteId=${row.id}`}>Versions</Link>
|
||||
</td>
|
||||
<td>{new Date(row.updatedAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { AgentJobSummary } from "@pipeline/shared";
|
||||
|
||||
export type AgentJobRow = {
|
||||
id: string;
|
||||
type: string;
|
||||
status: string;
|
||||
attempt: number;
|
||||
parentJobId: string | null;
|
||||
workspacePath: string | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number | null;
|
||||
durationMs: number | null;
|
||||
outputFiles: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export function buildAgentJobRows(jobs: readonly AgentJobSummary[]): AgentJobRow[] {
|
||||
return jobs.map((job) => ({
|
||||
id: job.id,
|
||||
type: job.job_type,
|
||||
status: job.status,
|
||||
attempt: job.attempt ?? 1,
|
||||
parentJobId: job.parent_job_id ?? null,
|
||||
workspacePath: job.workspace_path ?? null,
|
||||
stdout: job.stdout ?? "",
|
||||
stderr: job.stderr ?? "",
|
||||
exitCode: job.exit_code ?? null,
|
||||
durationMs: job.duration_ms ?? null,
|
||||
outputFiles: (job.output_files ?? []).map((file) => file.path).join(", "),
|
||||
error: job.error_category ?? job.error_message ?? "",
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { AgentJobRow } from "./model";
|
||||
|
||||
type AgentJobsTableProps = {
|
||||
rows: readonly AgentJobRow[];
|
||||
};
|
||||
|
||||
export function AgentJobsTable({ rows }: AgentJobsTableProps) {
|
||||
if (rows.length === 0) {
|
||||
return <p className="emptyState">No agent jobs yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job</th>
|
||||
<th>Status</th>
|
||||
<th>Attempt</th>
|
||||
<th>Runtime</th>
|
||||
<th>Workspace</th>
|
||||
<th>Logs</th>
|
||||
<th>Outputs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<div>{row.type}</div>
|
||||
<small>{row.id}</small>
|
||||
{row.parentJobId ? <div>retry of {row.parentJobId}</div> : null}
|
||||
</td>
|
||||
<td>{row.status}</td>
|
||||
<td>{row.attempt}</td>
|
||||
<td>
|
||||
<div>exit: {row.exitCode ?? "pending"}</div>
|
||||
<div>{row.durationMs ?? 0} ms</div>
|
||||
{row.error ? <div>{row.error}</div> : null}
|
||||
</td>
|
||||
<td>{row.workspacePath ?? "pending"}</td>
|
||||
<td>
|
||||
<pre className="logBlock">{row.stdout || "stdout pending"}</pre>
|
||||
{row.stderr ? <pre className="logBlock">{row.stderr}</pre> : null}
|
||||
</td>
|
||||
<td>{row.outputFiles || "none"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ArticleSummary, TargetSiteConfig } from "@pipeline/shared";
|
||||
|
||||
export type DashboardRow = {
|
||||
id: string;
|
||||
targetSiteName: string;
|
||||
workingTitle: string | null;
|
||||
briefDescription: string;
|
||||
status: string;
|
||||
publishingStatus: string;
|
||||
assignedEditor: string;
|
||||
updatedAt: string;
|
||||
nextAction: string;
|
||||
};
|
||||
|
||||
const EMPTY_ASSIGNED_EDITOR_LABEL = "Unassigned";
|
||||
|
||||
export function getNextActionForStatus(status: string): string {
|
||||
if (status === "ARTICLE_BRIEF_CREATED") {
|
||||
return "Run boundary questions";
|
||||
}
|
||||
if (status === "BOUNDARY_QUESTIONS_GENERATED") {
|
||||
return "Submit boundary answers";
|
||||
}
|
||||
if (status === "BOUNDARY_ANSWERS_SUBMITTED") {
|
||||
return "Create plan";
|
||||
}
|
||||
if (status === "PLAN_REVIEW_REQUIRED") {
|
||||
return "Gather evidence";
|
||||
}
|
||||
return "Continue workflow";
|
||||
}
|
||||
|
||||
function getAssignedEditorLabel(article: ArticleSummary): string {
|
||||
return article.assigned_editor_id ?? EMPTY_ASSIGNED_EDITOR_LABEL;
|
||||
}
|
||||
|
||||
function getTargetSiteName(
|
||||
article: ArticleSummary,
|
||||
siteById: Map<string, TargetSiteConfig>,
|
||||
): string {
|
||||
const site = siteById.get(article.target_site_id);
|
||||
return site?.name ?? "Unknown site";
|
||||
}
|
||||
|
||||
export function buildDashboardRows(
|
||||
articles: readonly ArticleSummary[],
|
||||
sites: readonly TargetSiteConfig[],
|
||||
): DashboardRow[] {
|
||||
const siteById = new Map(sites.map((site) => [site.id, site]));
|
||||
|
||||
return articles.map((article) => ({
|
||||
id: article.id,
|
||||
targetSiteName: getTargetSiteName(article, siteById),
|
||||
workingTitle: article.working_title ?? null,
|
||||
briefDescription: article.brief_description,
|
||||
status: article.status,
|
||||
publishingStatus: article.publishing_status,
|
||||
assignedEditor: getAssignedEditorLabel(article),
|
||||
updatedAt: article.updated_at,
|
||||
nextAction: getNextActionForStatus(article.status),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import type { DashboardRow } from "./model";
|
||||
|
||||
type DashboardTableProps = {
|
||||
rows: readonly DashboardRow[];
|
||||
};
|
||||
|
||||
export function DashboardTable({ rows }: DashboardTableProps) {
|
||||
if (rows.length === 0) {
|
||||
return <p className="emptyState">No article workflows yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Article title</th>
|
||||
<th>Target website</th>
|
||||
<th>Status</th>
|
||||
<th>Assigned editor</th>
|
||||
<th>Last updated</th>
|
||||
<th>Next action</th>
|
||||
<th>Publishing status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<Link href={`/articles/${row.id}`}>{row.workingTitle || row.briefDescription}</Link>
|
||||
</td>
|
||||
<td>{row.targetSiteName}</td>
|
||||
<td>{row.status}</td>
|
||||
<td>{row.assignedEditor}</td>
|
||||
<td>{new Date(row.updatedAt).toLocaleString()}</td>
|
||||
<td>{row.nextAction}</td>
|
||||
<td>{row.publishingStatus}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ArticleDetailResponse, WorkflowEventSummary } from "@pipeline/shared";
|
||||
|
||||
export type ArticleTimelineItem = Pick<
|
||||
WorkflowEventSummary,
|
||||
"id" | "event_type" | "to_status" | "from_status" | "actor_user_id" | "created_at"
|
||||
> & {
|
||||
articleId: string;
|
||||
};
|
||||
|
||||
export type DetailSummary = {
|
||||
articleId: string;
|
||||
status: string;
|
||||
publishingStatus: string;
|
||||
briefDescription: string;
|
||||
targetSite: string;
|
||||
updatedAt: string;
|
||||
timeline: ArticleTimelineItem[];
|
||||
};
|
||||
|
||||
export function buildArticleTimeline(detail: ArticleDetailResponse): ArticleTimelineItem[] {
|
||||
return (detail.workflow_events ?? []).map((event) => ({
|
||||
id: event.id,
|
||||
event_type: event.event_type,
|
||||
to_status: event.to_status,
|
||||
from_status: event.from_status,
|
||||
actor_user_id: event.actor_user_id,
|
||||
created_at: event.created_at,
|
||||
articleId: event.article_id,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildDetailSummary(detail: ArticleDetailResponse): DetailSummary {
|
||||
return {
|
||||
articleId: detail.article.id,
|
||||
status: detail.article.status,
|
||||
publishingStatus: detail.article.publishing_status,
|
||||
briefDescription: detail.article.brief_description,
|
||||
targetSite: detail.target_site?.name ?? "Unknown",
|
||||
updatedAt: detail.article.updated_at,
|
||||
timeline: buildArticleTimeline(detail),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import type { DetailSummary } from "./model";
|
||||
|
||||
type DetailShellProps = {
|
||||
summary: DetailSummary;
|
||||
};
|
||||
|
||||
export function ArticleDetailShell({ summary }: DetailShellProps) {
|
||||
const timeline = summary.timeline;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>Article detail</h2>
|
||||
<dl className="detailGrid">
|
||||
<div>
|
||||
<dt>Article ID</dt>
|
||||
<dd>{summary.articleId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>{summary.status}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Publishing status</dt>
|
||||
<dd>{summary.publishingStatus}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Target site</dt>
|
||||
<dd>{summary.targetSite}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last updated</dt>
|
||||
<dd>{new Date(summary.updatedAt).toLocaleString()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<h3>Brief</h3>
|
||||
<p>{summary.briefDescription}</p>
|
||||
|
||||
<ul className="inlineLinks">
|
||||
<li>
|
||||
<Link href={`/articles/${summary.articleId}/boundary-questions`}>
|
||||
Boundary questions
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={`/articles/${summary.articleId}/plans`}>Plan review</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={`/articles/${summary.articleId}/research`}>Research</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>Workflow timeline</h3>
|
||||
<ul className="timeline">
|
||||
{timeline.map((event) => (
|
||||
<li key={event.id}>
|
||||
<strong>{event.event_type}</strong> from {event.from_status ?? "—"} to{" "}
|
||||
{event.to_status ?? "—"} at {new Date(event.created_at).toLocaleString()}
|
||||
{event.actor_user_id ? <span> • actor {event.actor_user_id}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
{timeline.length === 0 ? <li>No workflow events yet.</li> : null}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { TargetSiteConfig } from "@pipeline/shared";
|
||||
|
||||
export type NewArticleDraft = {
|
||||
targetSiteId: string;
|
||||
briefDescription: string;
|
||||
workingTitle: string;
|
||||
contentType: string;
|
||||
primaryKeyword: string;
|
||||
};
|
||||
|
||||
export type FieldErrors = Record<string, string>;
|
||||
|
||||
export type NewArticleFormState = {
|
||||
values: NewArticleDraft;
|
||||
errors: FieldErrors;
|
||||
formError?: string;
|
||||
};
|
||||
|
||||
export function initialDraft(sites: readonly TargetSiteConfig[]): NewArticleDraft {
|
||||
return {
|
||||
targetSiteId: sites.length > 0 ? sites[0].id : "",
|
||||
briefDescription: "",
|
||||
workingTitle: "",
|
||||
contentType: "longform_guide",
|
||||
primaryKeyword: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function validateArticleDraft(values: NewArticleDraft): FieldErrors {
|
||||
const errors: FieldErrors = {};
|
||||
|
||||
if (!values.targetSiteId) {
|
||||
errors.targetSiteId = "Select a target site";
|
||||
}
|
||||
if (!values.briefDescription.trim()) {
|
||||
errors.briefDescription = "Brief description is required";
|
||||
}
|
||||
if (!values.contentType.trim()) {
|
||||
errors.contentType = "Content type is required";
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { TargetSiteConfig } from "@pipeline/shared";
|
||||
|
||||
import { ApiError, createArticle } from "@/shared/pipeline-api";
|
||||
import {
|
||||
initialDraft,
|
||||
validateArticleDraft,
|
||||
type FieldErrors,
|
||||
type NewArticleDraft,
|
||||
} from "./model";
|
||||
|
||||
type NewArticleFormProps = {
|
||||
sites: readonly TargetSiteConfig[];
|
||||
};
|
||||
|
||||
export function NewArticleForm({ sites }: NewArticleFormProps) {
|
||||
const router = useRouter();
|
||||
const [values, setValues] = useState<NewArticleDraft>(() => initialDraft(sites));
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [formError, setFormError] = useState("");
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() => !isSubmitting && sites.length > 0,
|
||||
[isSubmitting, sites.length],
|
||||
);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const nextErrors = validateArticleDraft(values);
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
setErrors(nextErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrors({});
|
||||
setFormError("");
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const response = await createArticle({
|
||||
target_site_id: values.targetSiteId,
|
||||
brief_description: values.briefDescription,
|
||||
working_title: values.workingTitle || null,
|
||||
language: "en",
|
||||
content_type: values.contentType,
|
||||
primary_keyword: values.primaryKeyword || null,
|
||||
});
|
||||
router.push(`/articles/${response.article.id}`);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setFormError(error.message || "Request failed");
|
||||
} else {
|
||||
setFormError("Unable to create article");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateField<K extends keyof NewArticleDraft>(field: K, value: NewArticleDraft[K]) {
|
||||
setValues((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="articleForm" onSubmit={handleSubmit}>
|
||||
<h2>Create article brief</h2>
|
||||
{formError ? <p className="formError">Error: {formError}</p> : null}
|
||||
<label htmlFor="targetSiteId">Target site</label>
|
||||
<select
|
||||
id="targetSiteId"
|
||||
value={values.targetSiteId}
|
||||
onChange={(event) => updateField("targetSiteId", event.target.value)}
|
||||
>
|
||||
<option value="">Select a site</option>
|
||||
{sites.map((site) => (
|
||||
<option key={site.id} value={site.id}>
|
||||
{site.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.targetSiteId ? <p className="fieldError">{errors.targetSiteId}</p> : null}
|
||||
|
||||
<label htmlFor="briefDescription">Brief description</label>
|
||||
<textarea
|
||||
id="briefDescription"
|
||||
value={values.briefDescription}
|
||||
onChange={(event) => updateField("briefDescription", event.target.value)}
|
||||
placeholder="Paste editor brief..."
|
||||
/>
|
||||
{errors.briefDescription ? (
|
||||
<p className="fieldError">{errors.briefDescription}</p>
|
||||
) : null}
|
||||
|
||||
<label htmlFor="workingTitle">Working title (optional)</label>
|
||||
<input
|
||||
id="workingTitle"
|
||||
type="text"
|
||||
value={values.workingTitle}
|
||||
onChange={(event) => updateField("workingTitle", event.target.value)}
|
||||
placeholder="Optional working title"
|
||||
/>
|
||||
|
||||
<label htmlFor="contentType">Content type</label>
|
||||
<input
|
||||
id="contentType"
|
||||
type="text"
|
||||
value={values.contentType}
|
||||
onChange={(event) => updateField("contentType", event.target.value)}
|
||||
/>
|
||||
{errors.contentType ? <p className="fieldError">{errors.contentType}</p> : null}
|
||||
|
||||
<label htmlFor="primaryKeyword">Primary keyword (optional)</label>
|
||||
<input
|
||||
id="primaryKeyword"
|
||||
type="text"
|
||||
value={values.primaryKeyword}
|
||||
onChange={(event) => updateField("primaryKeyword", event.target.value)}
|
||||
/>
|
||||
|
||||
<div className="formActions">
|
||||
<button type="submit" disabled={!canSubmit}>
|
||||
{isSubmitting ? "Creating..." : "Create article"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { BoundaryQuestionSummary } from "@pipeline/shared";
|
||||
|
||||
export type BoundaryQuestionFormRow = {
|
||||
id: string;
|
||||
category: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
isRequired: boolean;
|
||||
stateLabel: string;
|
||||
};
|
||||
|
||||
export type BoundaryQuestionErrors = Record<string, string>;
|
||||
|
||||
export function buildBoundaryQuestionRows(
|
||||
questions: readonly BoundaryQuestionSummary[],
|
||||
): BoundaryQuestionFormRow[] {
|
||||
return questions.map((question) => ({
|
||||
id: question.id,
|
||||
category: question.category,
|
||||
question: question.question,
|
||||
answer: question.answer ?? "",
|
||||
isRequired: question.is_required ?? true,
|
||||
stateLabel: question.is_required === false ? "Optional" : "Required",
|
||||
}));
|
||||
}
|
||||
|
||||
export function validateRequiredAnswers(
|
||||
rows: readonly BoundaryQuestionFormRow[],
|
||||
): BoundaryQuestionErrors {
|
||||
const errors: BoundaryQuestionErrors = {};
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.isRequired && !row.answer.trim()) {
|
||||
errors[row.id] = "Required answer";
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { BoundaryQuestionSummary } from "@pipeline/shared";
|
||||
|
||||
import { ApiError, generateBoundaryQuestions, submitBoundaryQuestions, updateBoundaryQuestion } from "@/shared/pipeline-api";
|
||||
import {
|
||||
buildBoundaryQuestionRows,
|
||||
validateRequiredAnswers,
|
||||
type BoundaryQuestionErrors,
|
||||
type BoundaryQuestionFormRow,
|
||||
} from "./model";
|
||||
|
||||
type BoundaryQuestionsEditorProps = {
|
||||
articleId: string;
|
||||
initialQuestions: readonly BoundaryQuestionSummary[];
|
||||
};
|
||||
|
||||
export function BoundaryQuestionsEditor({
|
||||
articleId,
|
||||
initialQuestions,
|
||||
}: BoundaryQuestionsEditorProps) {
|
||||
const router = useRouter();
|
||||
const [rows, setRows] = useState<BoundaryQuestionFormRow[]>(() =>
|
||||
buildBoundaryQuestionRows(initialQuestions),
|
||||
);
|
||||
const [errors, setErrors] = useState<BoundaryQuestionErrors>({});
|
||||
const [formMessage, setFormMessage] = useState("");
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
|
||||
const requiredProgress = useMemo(() => {
|
||||
const requiredRows = rows.filter((row) => row.isRequired);
|
||||
const answeredRows = requiredRows.filter((row) => row.answer.trim());
|
||||
return `${answeredRows.length}/${requiredRows.length}`;
|
||||
}, [rows]);
|
||||
|
||||
function updateRow(questionId: string, answer: string) {
|
||||
setRows((current) =>
|
||||
current.map((row) => (row.id === questionId ? { ...row, answer } : row)),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
setBusy(true);
|
||||
setFormMessage("");
|
||||
try {
|
||||
const response = await generateBoundaryQuestions(articleId);
|
||||
setRows(buildBoundaryQuestionRows(response.questions));
|
||||
setErrors({});
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setFormMessage(error instanceof ApiError ? error.message : "Unable to generate questions");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(row: BoundaryQuestionFormRow) {
|
||||
setBusy(true);
|
||||
setFormMessage("");
|
||||
try {
|
||||
const response = await updateBoundaryQuestion(articleId, row.id, {
|
||||
answer: row.answer.trim() || null,
|
||||
});
|
||||
setRows((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === row.id
|
||||
? buildBoundaryQuestionRows([response.question])[0]
|
||||
: candidate,
|
||||
),
|
||||
);
|
||||
setErrors((current) => {
|
||||
const next = { ...current };
|
||||
delete next[row.id];
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
setFormMessage(error instanceof ApiError ? error.message : "Unable to save answer");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const nextErrors = validateRequiredAnswers(rows);
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
setErrors(nextErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setFormMessage("");
|
||||
try {
|
||||
await submitBoundaryQuestions(articleId);
|
||||
router.push(`/articles/${articleId}`);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setFormMessage(error instanceof ApiError ? error.message : "Unable to submit answers");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="boundaryQuestions">
|
||||
<div className="sectionHeader">
|
||||
<div>
|
||||
<h2>Boundary questions</h2>
|
||||
<p>Required answers: {requiredProgress}</p>
|
||||
</div>
|
||||
<button type="button" onClick={handleGenerate} disabled={isBusy}>
|
||||
{rows.length > 0 ? "Regenerate" : "Generate"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formMessage ? <p className="formError">Error: {formMessage}</p> : null}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="emptyState">No boundary questions generated yet.</p>
|
||||
) : (
|
||||
<div className="questionList">
|
||||
{rows.map((row) => (
|
||||
<article className="questionItem" key={row.id}>
|
||||
<div className="questionHeader">
|
||||
<div>
|
||||
<strong>{row.category.replaceAll("_", " ")}</strong>
|
||||
<p>{row.question}</p>
|
||||
</div>
|
||||
<span className={row.isRequired ? "requiredBadge" : "optionalBadge"}>
|
||||
{row.stateLabel}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={row.answer}
|
||||
onChange={(event) => updateRow(row.id, event.target.value)}
|
||||
/>
|
||||
{errors[row.id] ? <p className="fieldError">{errors[row.id]}</p> : null}
|
||||
<div className="formActions">
|
||||
<button type="button" onClick={() => handleSave(row)} disabled={isBusy}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="formActions">
|
||||
<button type="button" onClick={handleSubmit} disabled={isBusy || rows.length === 0}>
|
||||
Submit boundary answers
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { PlanSummary } from "@pipeline/shared";
|
||||
|
||||
export type PlanReviewView = {
|
||||
id: string;
|
||||
version: number;
|
||||
status: string;
|
||||
recommendedTitle: string;
|
||||
readerPersona: string;
|
||||
searchIntent: string;
|
||||
sectionCount: number;
|
||||
evidenceNeeds: string;
|
||||
visualNeeds: string;
|
||||
seoNotes: string;
|
||||
sourceRequirements: string;
|
||||
excludedSources: string;
|
||||
tone: string;
|
||||
audience: string;
|
||||
};
|
||||
|
||||
export function buildPlanReviewViews(plans: readonly PlanSummary[]): PlanReviewView[] {
|
||||
return plans.map((plan) => ({
|
||||
id: plan.id,
|
||||
version: plan.version,
|
||||
status: plan.status,
|
||||
recommendedTitle: plan.recommended_title ?? "",
|
||||
readerPersona: plan.reader_persona ?? "",
|
||||
searchIntent: plan.search_intent ?? "",
|
||||
sectionCount: plan.sections?.length ?? 0,
|
||||
evidenceNeeds: (plan.evidence_needs ?? []).join(", "),
|
||||
visualNeeds: (plan.visual_needs ?? []).join(", "),
|
||||
seoNotes: (plan.seo_notes ?? []).join(", "),
|
||||
sourceRequirements: (plan.source_requirements ?? []).join(", "),
|
||||
excludedSources: (plan.excluded_sources ?? []).join(", "),
|
||||
tone: plan.tone ?? "",
|
||||
audience: plan.audience ?? "",
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { PlanSummary } from "@pipeline/shared";
|
||||
|
||||
import {
|
||||
ApiError,
|
||||
approveArticlePlan,
|
||||
generateArticlePlan,
|
||||
requestArticlePlanRevision,
|
||||
updateArticlePlan,
|
||||
} from "@/shared/pipeline-api";
|
||||
import { buildPlanReviewViews } from "./model";
|
||||
|
||||
type PlanReviewPanelProps = {
|
||||
articleId: string;
|
||||
initialPlans: readonly PlanSummary[];
|
||||
};
|
||||
|
||||
export function PlanReviewPanel({ articleId, initialPlans }: PlanReviewPanelProps) {
|
||||
const router = useRouter();
|
||||
const [plans, setPlans] = useState<PlanSummary[]>([...initialPlans]);
|
||||
const [message, setMessage] = useState("");
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const views = buildPlanReviewViews(plans);
|
||||
const currentPlan = plans.at(-1) ?? null;
|
||||
|
||||
async function runAction(action: () => Promise<unknown>) {
|
||||
setBusy(true);
|
||||
setMessage("");
|
||||
try {
|
||||
await action();
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof ApiError ? error.message : "Plan action failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleGenerate() {
|
||||
void runAction(async () => {
|
||||
const response = await generateArticlePlan(articleId);
|
||||
setPlans((current) => [...current, response.plan]);
|
||||
});
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
if (!currentPlan) {
|
||||
return;
|
||||
}
|
||||
void runAction(async () => {
|
||||
const response = await updateArticlePlan(articleId, currentPlan.id, {
|
||||
recommended_title: `${currentPlan.recommended_title ?? "Plan"} edited`,
|
||||
tone: currentPlan.tone ?? "Evidence-led",
|
||||
audience: currentPlan.audience ?? currentPlan.reader_persona,
|
||||
seo_notes: [...(currentPlan.seo_notes ?? []), "Editor adjustment"],
|
||||
source_requirements: currentPlan.source_requirements ?? [],
|
||||
excluded_sources: currentPlan.excluded_sources ?? [],
|
||||
visual_needs: currentPlan.visual_needs ?? [],
|
||||
});
|
||||
setPlans((current) => [...current, response.plan]);
|
||||
});
|
||||
}
|
||||
|
||||
function handleRevision() {
|
||||
if (!currentPlan) {
|
||||
return;
|
||||
}
|
||||
void runAction(() =>
|
||||
requestArticlePlanRevision(articleId, currentPlan.id, {
|
||||
notes: "Revise plan scope and evidence standard.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function handleApprove() {
|
||||
if (!currentPlan) {
|
||||
return;
|
||||
}
|
||||
void runAction(() => approveArticlePlan(articleId, currentPlan.id));
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="sectionHeader">
|
||||
<div>
|
||||
<h2>Plan review</h2>
|
||||
<p>{plans.length} versions</p>
|
||||
</div>
|
||||
<button type="button" onClick={handleGenerate} disabled={isBusy}>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
{message ? <p className="formError">Error: {message}</p> : null}
|
||||
{views.length === 0 ? (
|
||||
<p className="emptyState">No plans generated yet.</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Version</th>
|
||||
<th>Plan</th>
|
||||
<th>Requirements</th>
|
||||
<th>Controls</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{views.map((view) => (
|
||||
<tr key={view.id}>
|
||||
<td>
|
||||
<strong>v{view.version}</strong>
|
||||
<div>{view.status}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>{view.recommendedTitle}</div>
|
||||
<div>{view.readerPersona}</div>
|
||||
<div>{view.searchIntent}</div>
|
||||
<div>{view.sectionCount} sections</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>Sources: {view.sourceRequirements}</div>
|
||||
<div>Excluded: {view.excludedSources}</div>
|
||||
<div>Visuals: {view.visualNeeds}</div>
|
||||
<div>SEO: {view.seoNotes}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>Tone: {view.tone}</div>
|
||||
<div>Audience: {view.audience}</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<div className="formActions">
|
||||
<button type="button" onClick={handleEdit} disabled={isBusy || !currentPlan}>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" onClick={handleRevision} disabled={isBusy || !currentPlan}>
|
||||
Request revision
|
||||
</button>
|
||||
<button type="button" onClick={handleApprove} disabled={isBusy || !currentPlan}>
|
||||
Approve
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ResearchArtifactManifestSummary } from "@pipeline/shared";
|
||||
|
||||
export type ResearchSourceRow = {
|
||||
manifestId: string;
|
||||
sourceUrl: string;
|
||||
title: string;
|
||||
domain: string;
|
||||
type: string;
|
||||
summary: string;
|
||||
retrievedAt: string;
|
||||
artifactLink: string;
|
||||
objectKey: string;
|
||||
contentHash: string;
|
||||
};
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
export function buildResearchSourceRows(
|
||||
manifests: readonly ResearchArtifactManifestSummary[],
|
||||
): ResearchSourceRow[] {
|
||||
return manifests.flatMap((manifest) =>
|
||||
(manifest.artifacts ?? []).map((artifact) => {
|
||||
const metadata = artifact.metadata ?? {};
|
||||
return {
|
||||
manifestId: manifest.id,
|
||||
sourceUrl: artifact.source_url,
|
||||
title: stringValue(metadata.source_title),
|
||||
domain: stringValue(metadata.domain),
|
||||
type: stringValue(metadata.source_type) || artifact.artifact_type,
|
||||
summary: stringValue(metadata.summary),
|
||||
retrievedAt: stringValue(metadata.retrieved_at) || manifest.created_at,
|
||||
artifactLink: stringValue(metadata.artifact_link) || artifact.object_key,
|
||||
objectKey: artifact.object_key,
|
||||
contentHash: artifact.content_hash,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { ResearchArtifactManifestSummary } from "@pipeline/shared";
|
||||
|
||||
import { ApiError, startResearch } from "@/shared/pipeline-api";
|
||||
import { buildResearchSourceRows } from "./model";
|
||||
|
||||
type ResearchManifestPanelProps = {
|
||||
articleId: string;
|
||||
initialManifests: readonly ResearchArtifactManifestSummary[];
|
||||
};
|
||||
|
||||
export function ResearchManifestPanel({
|
||||
articleId,
|
||||
initialManifests,
|
||||
}: ResearchManifestPanelProps) {
|
||||
const router = useRouter();
|
||||
const [manifests, setManifests] = useState<ResearchArtifactManifestSummary[]>([
|
||||
...initialManifests,
|
||||
]);
|
||||
const [message, setMessage] = useState("");
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const rows = buildResearchSourceRows(manifests);
|
||||
|
||||
async function handleStart() {
|
||||
setBusy(true);
|
||||
setMessage("");
|
||||
try {
|
||||
const response = await startResearch(articleId);
|
||||
setManifests((current) => [...current, response.manifest]);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof ApiError ? error.message : "Unable to start research");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="sectionHeader">
|
||||
<div>
|
||||
<h2>Research</h2>
|
||||
<p>{manifests.length} runs</p>
|
||||
</div>
|
||||
<button type="button" onClick={handleStart} disabled={isBusy}>
|
||||
Start research
|
||||
</button>
|
||||
</div>
|
||||
{message ? <p className="formError">Error: {message}</p> : null}
|
||||
{rows.length === 0 ? (
|
||||
<p className="emptyState">No research sources yet.</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Summary</th>
|
||||
<th>Retrieved</th>
|
||||
<th>Artifact</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={`${row.manifestId}-${row.objectKey}`}>
|
||||
<td>
|
||||
<div>{row.title}</div>
|
||||
<a href={row.sourceUrl}>{row.domain || row.sourceUrl}</a>
|
||||
<div>{row.type}</div>
|
||||
</td>
|
||||
<td>{row.summary}</td>
|
||||
<td>{new Date(row.retrievedAt).toLocaleString()}</td>
|
||||
<td>
|
||||
<div>{row.artifactLink}</div>
|
||||
<small>{row.contentHash}</small>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { AgentJobsTable } from "@/features/agent-jobs/ui";
|
||||
import { buildAgentJobRows } from "@/features/agent-jobs/model";
|
||||
import { fetchAgentJobs } from "@/shared/pipeline-api";
|
||||
import { RoleNavigation } from "@/widgets/role-navigation";
|
||||
|
||||
export default async function AdminJobsPage() {
|
||||
const jobs = await fetchAgentJobs();
|
||||
const rows = buildAgentJobRows(jobs.jobs);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header className="pageHeader">
|
||||
<div>
|
||||
<h1>Agent Jobs</h1>
|
||||
<p>Monitor runner status, logs, outputs, and retry lineage.</p>
|
||||
</div>
|
||||
<Link href="/">Back to dashboard</Link>
|
||||
<span className="roleBadge">Admin demo</span>
|
||||
</header>
|
||||
<RoleNavigation role="ADMIN" />
|
||||
<section className="panel">
|
||||
<AgentJobsTable rows={rows} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { DEMO_ADMIN_EMAIL, fetchScriptConfigVersions, fetchTargetSitesAsUser, fetchTargetSite } from "@/shared/pipeline-api";
|
||||
import { RoleNavigation } from "@/widgets/role-navigation";
|
||||
import { AdminScriptVersionsPanel } from "@/features/admin-scripts/ui";
|
||||
|
||||
type SearchParams = {
|
||||
siteId?: string;
|
||||
};
|
||||
|
||||
type AdminScriptsPageProps = {
|
||||
searchParams?: SearchParams;
|
||||
};
|
||||
|
||||
export default async function AdminScriptsPage({
|
||||
searchParams,
|
||||
}: AdminScriptsPageProps) {
|
||||
const sitesResponse = await fetchTargetSitesAsUser(DEMO_ADMIN_EMAIL);
|
||||
const siteOptions = sitesResponse.map((site) => site.site);
|
||||
|
||||
if (siteOptions.length === 0) {
|
||||
return (
|
||||
<main>
|
||||
<header className="pageHeader">
|
||||
<div>
|
||||
<h1>Admin Script Versions</h1>
|
||||
<p>No target sites configured yet.</p>
|
||||
</div>
|
||||
<Link href="/admin/sites">Open site configuration</Link>
|
||||
</header>
|
||||
<RoleNavigation role="ADMIN" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedSiteId =
|
||||
searchParams?.siteId ?? siteOptions[0]?.id;
|
||||
const selectedSite =
|
||||
siteOptions.find((site) => site.id === selectedSiteId) ?? siteOptions[0];
|
||||
|
||||
const selectedResponse = await fetchTargetSite(selectedSite.id, DEMO_ADMIN_EMAIL);
|
||||
const activeSite = selectedResponse.site;
|
||||
const versionsResponse = await fetchScriptConfigVersions(
|
||||
selectedSite.id,
|
||||
DEMO_ADMIN_EMAIL,
|
||||
);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header className="pageHeader">
|
||||
<div>
|
||||
<h1>Admin Script Versions</h1>
|
||||
<p>Manage publishing YAML + transform scripts for each site.</p>
|
||||
</div>
|
||||
<Link href="/admin/sites">Open site configuration</Link>
|
||||
</header>
|
||||
<RoleNavigation role="ADMIN" />
|
||||
<section className="panel">
|
||||
<h2>Select site</h2>
|
||||
<ul className="inlineLinks">
|
||||
{siteOptions.map((site) => {
|
||||
const isActive = site.id === activeSite.id;
|
||||
return (
|
||||
<li key={site.id}>
|
||||
<Link
|
||||
href={`/admin/scripts?siteId=${site.id}`}
|
||||
className={isActive ? "activeLink" : undefined}
|
||||
>
|
||||
{site.name}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
<section className="panel">
|
||||
<AdminScriptVersionsPanel
|
||||
site={activeSite}
|
||||
initialVersions={versionsResponse.versions}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { DEMO_ADMIN_EMAIL } from "@/shared/pipeline-api";
|
||||
import { AdminSiteCreateForm } from "@/features/admin-site-config-create/ui";
|
||||
import { buildAdminSiteRows } from "@/features/admin-site-config/model";
|
||||
import { AdminSiteConfigTable } from "@/features/admin-site-config/ui";
|
||||
import { RoleNavigation } from "@/widgets/role-navigation";
|
||||
import { fetchTargetSitesAsUser } from "@/shared/pipeline-api";
|
||||
|
||||
export default async function AdminSitesPage() {
|
||||
const sitesResponse = await fetchTargetSitesAsUser(DEMO_ADMIN_EMAIL);
|
||||
const sites = sitesResponse.map((site) => site.site);
|
||||
const rows = buildAdminSiteRows(sites);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header className="pageHeader">
|
||||
<div>
|
||||
<h1>Admin Site Config</h1>
|
||||
<p>Manage target site publishing settings.</p>
|
||||
</div>
|
||||
<Link href="/">Back to dashboard</Link>
|
||||
<span className="roleBadge">Admin demo</span>
|
||||
</header>
|
||||
<RoleNavigation role="ADMIN" />
|
||||
<section className="panel">
|
||||
<h2>Existing sites</h2>
|
||||
<AdminSiteConfigTable rows={rows} />
|
||||
</section>
|
||||
<section className="panel">
|
||||
<AdminSiteCreateForm />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { BoundaryQuestionsEditor } from "@/features/boundary-questions/ui";
|
||||
import { fetchBoundaryQuestions } from "@/shared/pipeline-api";
|
||||
import { RoleNavigation } from "@/widgets/role-navigation";
|
||||
|
||||
type ArticleBoundaryQuestionsPageProps = {
|
||||
articleId: string;
|
||||
};
|
||||
|
||||
export default async function ArticleBoundaryQuestionsPage({
|
||||
articleId,
|
||||
}: ArticleBoundaryQuestionsPageProps) {
|
||||
const response = await fetchBoundaryQuestions(articleId);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header className="pageHeader">
|
||||
<div>
|
||||
<h1>Boundary questions</h1>
|
||||
<p>{response.questions.length} questions</p>
|
||||
</div>
|
||||
<Link href={`/articles/${articleId}`}>Back to article</Link>
|
||||
</header>
|
||||
<RoleNavigation role="EDITOR" />
|
||||
<section className="panel">
|
||||
<BoundaryQuestionsEditor
|
||||
articleId={articleId}
|
||||
initialQuestions={response.questions}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { RoleNavigation } from "@/widgets/role-navigation";
|
||||
import {
|
||||
buildDetailSummary,
|
||||
} from "@/features/article-detail/model";
|
||||
import { ArticleDetailShell } from "@/features/article-detail/ui";
|
||||
import { fetchArticleDetail } from "@/shared/pipeline-api";
|
||||
import { ApiError } from "@/shared/pipeline-api";
|
||||
|
||||
type ArticleDetailPageProps = {
|
||||
articleId: string;
|
||||
};
|
||||
|
||||
export default async function ArticleDetailPage({
|
||||
articleId,
|
||||
}: ArticleDetailPageProps) {
|
||||
try {
|
||||
const detail = await fetchArticleDetail(articleId);
|
||||
const summary = buildDetailSummary(detail);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header className="pageHeader">
|
||||
<div>
|
||||
<h1>Article detail</h1>
|
||||
<p>{summary.status}</p>
|
||||
</div>
|
||||
<Link href="/">Back to dashboard</Link>
|
||||
</header>
|
||||
<RoleNavigation role="EDITOR" />
|
||||
<section className="panel">
|
||||
<ArticleDetailShell summary={summary} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
return (
|
||||
<main>
|
||||
<header className="pageHeader">
|
||||
<h1>Article not found</h1>
|
||||
<Link href="/">Back to dashboard</Link>
|
||||
</header>
|
||||
<RoleNavigation role="EDITOR" />
|
||||
<section className="panel">
|
||||
<p>The requested article does not exist.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { TargetSiteConfigResponse } from "@pipeline/shared";
|
||||
import Link from "next/link";
|
||||
|
||||
import { RoleNavigation } from "@/widgets/role-navigation";
|
||||
import { NewArticleForm } from "@/features/article-intake/ui";
|
||||
import { fetchTargetSites } from "@/shared/pipeline-api";
|
||||
|
||||
export default async function NewArticlePage() {
|
||||
const sites: TargetSiteConfigResponse[] = await fetchTargetSites();
|
||||
const targetSites = sites.map((site) => site.site);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header className="pageHeader">
|
||||
<div>
|
||||
<h1>New Article</h1>
|
||||
<p>Draft a new editorial brief for an existing site.</p>
|
||||
</div>
|
||||
<Link href="/">Back to dashboard</Link>
|
||||
</header>
|
||||
<RoleNavigation role="EDITOR" />
|
||||
<section className="panel">
|
||||
<NewArticleForm sites={targetSites} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { PlanReviewPanel } from "@/features/plan-review/ui";
|
||||
import { fetchArticlePlans } from "@/shared/pipeline-api";
|
||||
import { RoleNavigation } from "@/widgets/role-navigation";
|
||||
|
||||
type ArticlePlansPageProps = {
|
||||
articleId: string;
|
||||
};
|
||||
|
||||
export default async function ArticlePlansPage({ articleId }: ArticlePlansPageProps) {
|
||||
const response = await fetchArticlePlans(articleId);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header className="pageHeader">
|
||||
<div>
|
||||
<h1>Plan review</h1>
|
||||
<p>{response.plans.length} versions</p>
|
||||
</div>
|
||||
<Link href={`/articles/${articleId}`}>Back to article</Link>
|
||||
</header>
|
||||
<RoleNavigation role="EDITOR" />
|
||||
<section className="panel">
|
||||
<PlanReviewPanel articleId={articleId} initialPlans={response.plans} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { ResearchManifestPanel } from "@/features/research-manifest/ui";
|
||||
import { fetchResearch } from "@/shared/pipeline-api";
|
||||
import { RoleNavigation } from "@/widgets/role-navigation";
|
||||
|
||||
type ArticleResearchPageProps = {
|
||||
articleId: string;
|
||||
};
|
||||
|
||||
export default async function ArticleResearchPage({
|
||||
articleId,
|
||||
}: ArticleResearchPageProps) {
|
||||
const response = await fetchResearch(articleId);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header className="pageHeader">
|
||||
<div>
|
||||
<h1>Research</h1>
|
||||
<p>{response.manifests?.length ?? 0} runs</p>
|
||||
</div>
|
||||
<Link href={`/articles/${articleId}`}>Back to article</Link>
|
||||
</header>
|
||||
<RoleNavigation role="EDITOR" />
|
||||
<section className="panel">
|
||||
<ResearchManifestPanel
|
||||
articleId={articleId}
|
||||
initialManifests={response.manifests ?? []}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import type { ArticleListResponse, TargetSiteConfigResponse } from "@pipeline/shared";
|
||||
|
||||
import { RoleNavigation } from "@/widgets/role-navigation";
|
||||
import { buildDashboardRows } from "@/features/article-dashboard/model";
|
||||
import { DashboardTable } from "@/features/article-dashboard/ui";
|
||||
import { fetchArticles, fetchTargetSites } from "@/shared/pipeline-api";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const [articlesResponse, sitesResponse]: [
|
||||
ArticleListResponse,
|
||||
TargetSiteConfigResponse[],
|
||||
] = await Promise.all([fetchArticles(), fetchTargetSites()]);
|
||||
|
||||
const rows = buildDashboardRows(
|
||||
articlesResponse.articles,
|
||||
sitesResponse.map((site) => site.site),
|
||||
);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header className="pageHeader">
|
||||
<div>
|
||||
<h1>AI Content Pipeline</h1>
|
||||
<p>{rows.length} active article workflows</p>
|
||||
</div>
|
||||
<Link href="/articles/new">New article</Link>
|
||||
<span className="roleBadge">Editor demo</span>
|
||||
</header>
|
||||
<RoleNavigation role="EDITOR" />
|
||||
<section className="panel">
|
||||
<DashboardTable rows={rows} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import type {
|
||||
ArticleCreateRequest,
|
||||
ArticleCreateResponse,
|
||||
ArticleDetailResponse,
|
||||
AgentJobListResponse,
|
||||
AgentJobResponse,
|
||||
AgentJobTestCodexRequest,
|
||||
ArticleListResponse,
|
||||
BoundaryQuestionListResponse,
|
||||
BoundaryQuestionResponse,
|
||||
BoundaryQuestionUpdateRequest,
|
||||
CurrentUserResponse,
|
||||
PlanListResponse,
|
||||
PlanResponse,
|
||||
PlanRevisionRequest,
|
||||
PlanUpdateRequest,
|
||||
ResearchListResponse,
|
||||
ResearchStartResponse,
|
||||
ReviewActionResponse,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionListResponse,
|
||||
ScriptConfigVersionResponse,
|
||||
TargetSiteConfigResponse,
|
||||
TargetSiteConfigCreateRequest,
|
||||
} from "@pipeline/shared";
|
||||
|
||||
export const DEMO_EDITOR_EMAIL = "editor@example.com";
|
||||
export const DEMO_ADMIN_EMAIL = "admin@example.com";
|
||||
const DEFAULT_LOCAL_BACKEND_URL = "http://localhost:8000";
|
||||
|
||||
type ApiRequestConfig = {
|
||||
userEmail?: string;
|
||||
};
|
||||
|
||||
export type ApiErrorPayload = {
|
||||
status: number;
|
||||
message: string;
|
||||
detail?: unknown;
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
detail?: unknown;
|
||||
|
||||
constructor(payload: ApiErrorPayload) {
|
||||
super(payload.message);
|
||||
this.name = "ApiError";
|
||||
this.status = payload.status;
|
||||
this.detail = payload.detail;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBackendBaseUrl(): string {
|
||||
if (typeof window === "undefined") {
|
||||
return process.env.BACKEND_URL ?? process.env.NEXT_PUBLIC_BACKEND_URL ?? DEFAULT_LOCAL_BACKEND_URL;
|
||||
}
|
||||
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ??
|
||||
process.env.BACKEND_URL ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
function buildApiUrl(path: string): string {
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
const base = resolveBackendBaseUrl();
|
||||
if (!base || base === "/") {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
return `${base.replace(/\/$/, "")}${normalizedPath}`;
|
||||
}
|
||||
|
||||
async function parseBody(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
userEmail?: string,
|
||||
): Promise<T> {
|
||||
const isFormData = options.body instanceof FormData;
|
||||
const effectiveUserEmail = userEmail ?? DEMO_EDITOR_EMAIL;
|
||||
const response = await fetch(buildApiUrl(path), {
|
||||
...options,
|
||||
headers: {
|
||||
...(!isFormData
|
||||
? { "Content-Type": "application/json" }
|
||||
: {}),
|
||||
"X-Demo-User-Email": effectiveUserEmail,
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
cache: options.cache ?? "no-store",
|
||||
});
|
||||
|
||||
const body = await parseBody(response);
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof body === "object" && body !== null && "detail" in body
|
||||
? String((body as { detail?: unknown }).detail)
|
||||
: response.statusText;
|
||||
throw new ApiError({
|
||||
status: response.status,
|
||||
message,
|
||||
detail: body,
|
||||
});
|
||||
}
|
||||
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export function apiGet<T>(
|
||||
path: string,
|
||||
options?: ApiRequestConfig,
|
||||
): Promise<T> {
|
||||
return requestJson<T>(path, { method: "GET" }, options?.userEmail);
|
||||
}
|
||||
|
||||
export function apiPost<TRequest, TResponse>(
|
||||
path: string,
|
||||
body: TRequest | undefined,
|
||||
options?: ApiRequestConfig,
|
||||
): Promise<TResponse> {
|
||||
const requestBody = body === undefined ? undefined : JSON.stringify(body);
|
||||
return requestJson<TResponse>(
|
||||
path,
|
||||
{
|
||||
method: "POST",
|
||||
...(requestBody === undefined ? {} : { body: requestBody }),
|
||||
},
|
||||
options?.userEmail,
|
||||
);
|
||||
}
|
||||
|
||||
export function apiPatch<TRequest, TResponse>(
|
||||
path: string,
|
||||
body: TRequest,
|
||||
options?: ApiRequestConfig,
|
||||
): Promise<TResponse> {
|
||||
return requestJson<TResponse>(
|
||||
path,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
options?.userEmail,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchCurrentUser(): Promise<CurrentUserResponse> {
|
||||
return apiGet<CurrentUserResponse>("/api/me");
|
||||
}
|
||||
|
||||
export function fetchAgentJobs(
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<AgentJobListResponse> {
|
||||
return apiGet<AgentJobListResponse>("/api/agent-jobs", { userEmail });
|
||||
}
|
||||
|
||||
export function createTestCodexJob(
|
||||
request: AgentJobTestCodexRequest = { fake_result: "valid" },
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<AgentJobResponse> {
|
||||
return apiPost<AgentJobTestCodexRequest, AgentJobResponse>(
|
||||
"/api/agent-jobs/test-codex",
|
||||
request,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function retryAgentJob(
|
||||
jobId: string,
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<AgentJobResponse> {
|
||||
return apiPost<unknown, AgentJobResponse>(
|
||||
`/api/agent-jobs/${jobId}/retry`,
|
||||
undefined,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function cancelAgentJob(
|
||||
jobId: string,
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<AgentJobResponse> {
|
||||
return apiPost<unknown, AgentJobResponse>(
|
||||
`/api/agent-jobs/${jobId}/cancel`,
|
||||
undefined,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchTargetSites(): Promise<TargetSiteConfigResponse[]> {
|
||||
return apiGet<TargetSiteConfigResponse[]>("/api/sites");
|
||||
}
|
||||
|
||||
export function fetchTargetSitesAsUser(
|
||||
userEmail: string,
|
||||
): Promise<TargetSiteConfigResponse[]> {
|
||||
return apiGet<TargetSiteConfigResponse[]>("/api/sites", { userEmail });
|
||||
}
|
||||
|
||||
export function fetchTargetSite(
|
||||
siteId: string,
|
||||
userEmail = DEMO_EDITOR_EMAIL,
|
||||
): Promise<TargetSiteConfigResponse> {
|
||||
return apiGet<TargetSiteConfigResponse>(`/api/sites/${siteId}`, {
|
||||
userEmail,
|
||||
});
|
||||
}
|
||||
|
||||
export function createTargetSite(
|
||||
request: TargetSiteConfigCreateRequest,
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<TargetSiteConfigResponse> {
|
||||
return apiPost<TargetSiteConfigCreateRequest, TargetSiteConfigResponse>(
|
||||
"/api/sites",
|
||||
request,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchScriptConfigVersions(
|
||||
siteId: string,
|
||||
userEmail = DEMO_EDITOR_EMAIL,
|
||||
): Promise<ScriptConfigVersionListResponse> {
|
||||
return apiGet<ScriptConfigVersionListResponse>(
|
||||
`/api/sites/${siteId}/publishing-config/versions`,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function createScriptConfigVersion(
|
||||
siteId: string,
|
||||
request: ScriptConfigVersionCreateRequest,
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<ScriptConfigVersionResponse> {
|
||||
return apiPost<ScriptConfigVersionCreateRequest, ScriptConfigVersionResponse>(
|
||||
`/api/sites/${siteId}/publishing-config/versions`,
|
||||
request,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function activateScriptConfigVersion(
|
||||
siteId: string,
|
||||
versionId: string,
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<ScriptConfigVersionResponse> {
|
||||
return apiPost<unknown, ScriptConfigVersionResponse>(
|
||||
`/api/sites/${siteId}/publishing-config/versions/${versionId}/activate`,
|
||||
undefined,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function rollbackScriptConfigVersion(
|
||||
siteId: string,
|
||||
versionId: string,
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<ScriptConfigVersionResponse> {
|
||||
return apiPost<unknown, ScriptConfigVersionResponse>(
|
||||
`/api/sites/${siteId}/publishing-config/versions/${versionId}/rollback`,
|
||||
undefined,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchArticles(): Promise<ArticleListResponse> {
|
||||
return apiGet<ArticleListResponse>("/api/articles");
|
||||
}
|
||||
|
||||
export function fetchArticleDetail(
|
||||
articleId: string,
|
||||
): Promise<ArticleDetailResponse> {
|
||||
return apiGet<ArticleDetailResponse>(`/api/articles/${articleId}`);
|
||||
}
|
||||
|
||||
export function createArticle(
|
||||
request: ArticleCreateRequest,
|
||||
): Promise<ArticleCreateResponse> {
|
||||
return apiPost<ArticleCreateRequest, ArticleCreateResponse>(
|
||||
"/api/articles",
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
export function generateBoundaryQuestions(
|
||||
articleId: string,
|
||||
): Promise<BoundaryQuestionListResponse> {
|
||||
return apiPost<unknown, BoundaryQuestionListResponse>(
|
||||
`/api/articles/${articleId}/boundary-questions/generate`,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchBoundaryQuestions(
|
||||
articleId: string,
|
||||
): Promise<BoundaryQuestionListResponse> {
|
||||
return apiGet<BoundaryQuestionListResponse>(
|
||||
`/api/articles/${articleId}/boundary-questions`,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateBoundaryQuestion(
|
||||
articleId: string,
|
||||
questionId: string,
|
||||
request: BoundaryQuestionUpdateRequest,
|
||||
): Promise<BoundaryQuestionResponse> {
|
||||
return apiPatch<BoundaryQuestionUpdateRequest, BoundaryQuestionResponse>(
|
||||
`/api/articles/${articleId}/boundary-questions/${questionId}`,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
export function submitBoundaryQuestions(
|
||||
articleId: string,
|
||||
): Promise<ArticleCreateResponse> {
|
||||
return apiPost<unknown, ArticleCreateResponse>(
|
||||
`/api/articles/${articleId}/boundary-questions/submit`,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function generateArticlePlan(articleId: string): Promise<PlanResponse> {
|
||||
return apiPost<unknown, PlanResponse>(
|
||||
`/api/articles/${articleId}/plan/generate`,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchArticlePlans(articleId: string): Promise<PlanListResponse> {
|
||||
return apiGet<PlanListResponse>(`/api/articles/${articleId}/plans`);
|
||||
}
|
||||
|
||||
export function updateArticlePlan(
|
||||
articleId: string,
|
||||
planId: string,
|
||||
request: PlanUpdateRequest,
|
||||
): Promise<PlanResponse> {
|
||||
return apiPatch<PlanUpdateRequest, PlanResponse>(
|
||||
`/api/articles/${articleId}/plans/${planId}`,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
export function approveArticlePlan(
|
||||
articleId: string,
|
||||
planId: string,
|
||||
): Promise<ReviewActionResponse> {
|
||||
return apiPost<unknown, ReviewActionResponse>(
|
||||
`/api/articles/${articleId}/plans/${planId}/approve`,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function requestArticlePlanRevision(
|
||||
articleId: string,
|
||||
planId: string,
|
||||
request: PlanRevisionRequest,
|
||||
): Promise<ReviewActionResponse> {
|
||||
return apiPost<PlanRevisionRequest, ReviewActionResponse>(
|
||||
`/api/articles/${articleId}/plans/${planId}/request-revision`,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
export function startResearch(articleId: string): Promise<ResearchStartResponse> {
|
||||
return apiPost<unknown, ResearchStartResponse>(
|
||||
`/api/articles/${articleId}/research/start`,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchResearch(articleId: string): Promise<ResearchListResponse> {
|
||||
return apiGet<ResearchListResponse>(`/api/articles/${articleId}/research`);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { getRoleNavigationItems, ROLE_NAVIGATION_ITEMS } from "./model";
|
||||
export type { NavigationItem } from "./model";
|
||||
export { RoleNavigation } from "./ui";
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Role } from "@pipeline/shared";
|
||||
|
||||
export type NavigationItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
href: string;
|
||||
roles: readonly Role[];
|
||||
};
|
||||
|
||||
export const ROLE_NAVIGATION_ITEMS = [
|
||||
{
|
||||
id: "dashboard",
|
||||
label: "Dashboard",
|
||||
href: "/",
|
||||
roles: ["ADMIN", "EDITOR"],
|
||||
},
|
||||
{
|
||||
id: "new-article",
|
||||
label: "New article",
|
||||
href: "/articles/new",
|
||||
roles: ["ADMIN", "EDITOR"],
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
label: "Review",
|
||||
href: "/review",
|
||||
roles: ["ADMIN", "EDITOR"],
|
||||
},
|
||||
{
|
||||
id: "site-config",
|
||||
label: "Site configuration",
|
||||
href: "/admin/sites",
|
||||
roles: ["ADMIN"],
|
||||
},
|
||||
{
|
||||
id: "script-versions",
|
||||
label: "Script versions",
|
||||
href: "/admin/scripts",
|
||||
roles: ["ADMIN"],
|
||||
},
|
||||
{
|
||||
id: "agent-jobs",
|
||||
label: "Agent jobs",
|
||||
href: "/admin/jobs",
|
||||
roles: ["ADMIN"],
|
||||
},
|
||||
] as const satisfies readonly NavigationItem[];
|
||||
|
||||
export function getRoleNavigationItems(role: Role): NavigationItem[] {
|
||||
return ROLE_NAVIGATION_ITEMS.filter((item) =>
|
||||
item.roles.some((allowedRole) => allowedRole === role),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Role } from "@pipeline/shared";
|
||||
|
||||
import { getRoleNavigationItems } from "./model";
|
||||
|
||||
type RoleNavigationProps = {
|
||||
role: Role;
|
||||
};
|
||||
|
||||
export function RoleNavigation({ role }: RoleNavigationProps) {
|
||||
const items = getRoleNavigationItems(role);
|
||||
|
||||
return (
|
||||
<nav aria-label="Primary navigation">
|
||||
<ul className="roleNavigation">
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<a href={item.href}>{item.label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import ts from "typescript";
|
||||
|
||||
const testDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sourcePath = path.resolve(
|
||||
testDir,
|
||||
"../src/features/admin-scripts/model.ts",
|
||||
);
|
||||
const source = readFileSync(sourcePath, "utf8");
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||||
});
|
||||
|
||||
const moduleExports = {};
|
||||
new Function("exports", compiled.outputText)(moduleExports);
|
||||
|
||||
const {
|
||||
buildAdminScriptVersionRows,
|
||||
defaultScriptVersionDraft,
|
||||
buildCreateScriptVersionPayload,
|
||||
parseJsonObject,
|
||||
} = moduleExports;
|
||||
|
||||
const rows = buildAdminScriptVersionRows([
|
||||
{
|
||||
id: "11111111-1111-1111-1111-111111111101",
|
||||
target_site_id: "22222222-2222-2222-2222-222222222222",
|
||||
version: 1,
|
||||
status: "ACTIVE",
|
||||
diff: { summary: "seed" },
|
||||
publishing_yaml_hash: "abc",
|
||||
transform_script_hash: "def",
|
||||
rollback_target_version_id: null,
|
||||
created_by: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
activated_at: "2025-01-01T01:00:00Z",
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].isActive, true);
|
||||
assert.equal(rows[0].diff, '{"summary":"seed"}');
|
||||
|
||||
const payload = buildCreateScriptVersionPayload(defaultScriptVersionDraft());
|
||||
assert.equal(payload.activate, false);
|
||||
assert.ok(typeof payload.publishing_yaml === "string");
|
||||
assert.equal(payload.rollback_target_version_id, null);
|
||||
|
||||
assert.deepEqual(parseJsonObject('{"a":1}'), { a: 1 });
|
||||
assert.equal(parseJsonObject("[]"), null);
|
||||
assert.deepEqual(parseJsonObject(""), {});
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import ts from "typescript";
|
||||
|
||||
const testDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sourcePath = path.resolve(testDir, "../src/features/agent-jobs/model.ts");
|
||||
const source = readFileSync(sourcePath, "utf8");
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||||
});
|
||||
|
||||
const moduleExports = {};
|
||||
new Function("exports", compiled.outputText)(moduleExports);
|
||||
|
||||
const { buildAgentJobRows } = moduleExports;
|
||||
|
||||
const rows = buildAgentJobRows([
|
||||
{
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
article_id: null,
|
||||
parent_job_id: "00000000-0000-0000-0000-000000000000",
|
||||
attempt: 2,
|
||||
job_type: "TEST_CODEX",
|
||||
agent_profile: "fake-codex",
|
||||
status: "SUCCEEDED",
|
||||
workspace_path: "/tmp/job",
|
||||
input_files: [{ path: "inputs/job.json" }],
|
||||
output_files: [{ path: "outputs/result.json" }],
|
||||
error_category: null,
|
||||
error_message: null,
|
||||
stdout: "done",
|
||||
stderr: "",
|
||||
exit_code: 0,
|
||||
duration_ms: 5,
|
||||
queued_at: "2026-05-21T00:00:00Z",
|
||||
started_at: "2026-05-21T00:00:01Z",
|
||||
finished_at: "2026-05-21T00:00:02Z",
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].status, "SUCCEEDED");
|
||||
assert.equal(rows[0].attempt, 2);
|
||||
assert.equal(rows[0].parentJobId, "00000000-0000-0000-0000-000000000000");
|
||||
assert.equal(rows[0].outputFiles, "outputs/result.json");
|
||||
assert.equal(rows[0].stdout, "done");
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user