Compare commits
25
Commits
d42f0c4392
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8937d2864 | ||
|
|
5f3e543ea2 | ||
|
|
baff9211e8 | ||
|
|
cbd24d4a1c | ||
|
|
d1ae5c4437 | ||
|
|
5ea0d5978a | ||
|
|
7667819c46 | ||
|
|
95fec3ba26 | ||
|
|
318ae790ef | ||
|
|
d3990b4019 | ||
|
|
6dcbe0e36c | ||
|
|
8243f20b0e | ||
|
|
c3914bf8be | ||
|
|
8ebb5ad623 | ||
|
|
7e1b05067c | ||
|
|
7d4a79acb5 | ||
|
|
5b27d1a3ca | ||
|
|
b7e2fa34b2 | ||
|
|
8aa92ea728 | ||
|
|
4fd837349f | ||
|
|
ff1a2f78c0 | ||
|
|
cf84400118 | ||
|
|
7972e9a95f | ||
|
|
7a11d50aa7 | ||
|
|
a0ae06adfa |
@@ -2,6 +2,7 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
node_modules/
|
node_modules/
|
||||||
.next/
|
.next/
|
||||||
|
*.tsbuildinfo
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.pytest_cache/
|
.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
|
||||||
@@ -8,6 +8,10 @@ The system accepts a short article description, asks boundary-setting questions,
|
|||||||
|
|
||||||
v1 is an internal or single-tenant editorial tool. It is not a customer-facing multi-tenant SaaS product.
|
v1 is an internal or single-tenant editorial tool. It is not a customer-facing multi-tenant SaaS product.
|
||||||
|
|
||||||
|
### Main principle
|
||||||
|
|
||||||
|
The workflow is a first-class Admin-managed product object, not a hard-coded backend status chain. The pipeline is gate-driven: each workflow stage defines its purpose, ordered parts, owner role, runner profile, required inputs, outputs, and acceptance criteria. Runtime execution cannot move to the next stage until the current stage is complete and every required human approval or validation gate has passed.
|
||||||
|
|
||||||
Chosen architecture:
|
Chosen architecture:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -50,6 +54,7 @@ Claude Code can be supported as an optional internal runner, but not as the main
|
|||||||
14. Multi-site configuration
|
14. Multi-site configuration
|
||||||
15. Job history and audit trail
|
15. Job history and audit trail
|
||||||
16. Codex CLI-based agent execution
|
16. Codex CLI-based agent execution
|
||||||
|
17. Admin-managed workflow templates and editable workflow stages
|
||||||
```
|
```
|
||||||
|
|
||||||
## Out of scope for v1
|
## Out of scope for v1
|
||||||
@@ -76,13 +81,13 @@ The publishing step in v1 should create a **direct commit to the configured prod
|
|||||||
|
|
||||||
## Admin
|
## Admin
|
||||||
|
|
||||||
Can configure target websites, pipeline parameters, agent runner profiles, prompt versions, publishing YAML, and site-specific transformation/upload scripts.
|
Can configure target websites, workflow templates, workflow stages, pipeline parameters, agent runner profiles, prompt versions, publishing YAML, and site-specific transformation/upload scripts.
|
||||||
|
|
||||||
## Editor
|
## Editor
|
||||||
|
|
||||||
Can create briefs, answer boundary questions, run the pipeline, edit intermediate results, approve plans, edit drafts, approve final content, and create publish commits.
|
Can create briefs, answer boundary questions, run the pipeline, edit intermediate results, approve plans, edit drafts, approve final content, and create publish commits.
|
||||||
|
|
||||||
Only Admins can edit upload scripts and pipeline configuration. Admins are fully trusted code operators because admin-defined transformation scripts run directly on the runner host inside checked-out site repositories.
|
Only Admins can edit workflow configuration, upload scripts, and pipeline configuration. Admins are fully trusted code operators because admin-defined transformation scripts run directly on the runner host inside checked-out site repositories.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
.PHONY: dev down smoke
|
PYTHON ?= python3
|
||||||
|
|
||||||
|
.PHONY: contracts dev down migrate seed smoke
|
||||||
|
|
||||||
|
contracts:
|
||||||
|
$(PYTHON) scripts/generate_openapi_contracts.py
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
@@ -6,5 +11,11 @@ dev:
|
|||||||
down:
|
down:
|
||||||
docker compose down --remove-orphans
|
docker compose down --remove-orphans
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
seed:
|
||||||
|
PYTHONPATH=apps/backend $(PYTHON) -m src.infrastructure.seed_data
|
||||||
|
|
||||||
smoke:
|
smoke:
|
||||||
bash tests/smoke/public-health.sh
|
bash tests/smoke/public-health.sh
|
||||||
|
|||||||
@@ -39,3 +39,18 @@ make smoke
|
|||||||
The smoke test starts Docker Compose, checks frontend/backend/runner health
|
The smoke test starts Docker Compose, checks frontend/backend/runner health
|
||||||
endpoints, and verifies that the backend can connect to Postgres, Redis, and
|
endpoints, and verifies that the backend can connect to Postgres, Redis, and
|
||||||
S3-compatible object storage.
|
S3-compatible object storage.
|
||||||
|
|
||||||
|
## End-to-End Demo
|
||||||
|
|
||||||
|
Run full deterministic demo stack:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The Compose profile is configured for demo mode and does not require external
|
||||||
|
credentials for Codex/search/WHOIS/cloud storage/GitHub.
|
||||||
|
|
||||||
|
Detailed steps (roles, happy path, failure/retry path, artifact inspection):
|
||||||
|
|
||||||
|
- [Demo Stack Guide](docs/demo-stack-guide.md)
|
||||||
|
|||||||
+41
@@ -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
|
||||||
@@ -5,9 +5,15 @@ ENV PYTHONUNBUFFERED=1
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends git nodejs npm \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY apps/backend/requirements.txt ./requirements.txt
|
COPY apps/backend/requirements.txt ./requirements.txt
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY alembic.ini ./alembic.ini
|
||||||
|
COPY apps/backend/alembic ./apps/backend/alembic
|
||||||
COPY apps/backend/src ./src
|
COPY apps/backend/src ./src
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|||||||
@@ -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
|
fastapi==0.115.6
|
||||||
|
httpx==0.28.1
|
||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.34.0
|
||||||
|
alembic==1.14.0
|
||||||
|
SQLAlchemy==2.0.36
|
||||||
psycopg[binary]==3.2.3
|
psycopg[binary]==3.2.3
|
||||||
redis==5.2.1
|
redis==5.2.1
|
||||||
boto3==1.35.90
|
boto3==1.35.90
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from src.domain.contracts import (
|
||||||
|
AgentJobErrorCategory,
|
||||||
|
AgentJobListResponse,
|
||||||
|
AgentJobOutput,
|
||||||
|
AgentJobResponse,
|
||||||
|
AgentJobStatus,
|
||||||
|
AgentJobSummary,
|
||||||
|
AgentJobTestCodexRequest,
|
||||||
|
AgentJobType,
|
||||||
|
Role,
|
||||||
|
)
|
||||||
|
from src.application.observability import evaluate_retry_policy, project_job_for_view
|
||||||
|
from src.application.demo_runtime import (
|
||||||
|
build_demo_section_completion_payload,
|
||||||
|
should_auto_retry_section_job,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
VALID_FAKE_AGENT_PROFILE = "fake-codex"
|
||||||
|
INVALID_SCHEMA_FAKE_AGENT_PROFILE = "fake-invalid-schema"
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_codex_job(
|
||||||
|
repository: object,
|
||||||
|
request: AgentJobTestCodexRequest,
|
||||||
|
) -> AgentJobResponse:
|
||||||
|
agent_profile = (
|
||||||
|
INVALID_SCHEMA_FAKE_AGENT_PROFILE
|
||||||
|
if request.fake_result == "invalid_schema"
|
||||||
|
else VALID_FAKE_AGENT_PROFILE
|
||||||
|
)
|
||||||
|
job = repository.agent_jobs.create(
|
||||||
|
article_id=None,
|
||||||
|
parent_job_id=None,
|
||||||
|
attempt=1,
|
||||||
|
job_type=AgentJobType.TEST_CODEX,
|
||||||
|
agent_profile=agent_profile,
|
||||||
|
status=AgentJobStatus.QUEUED,
|
||||||
|
input_files=[{"path": "inputs/job.json"}],
|
||||||
|
queued_at=_now(),
|
||||||
|
)
|
||||||
|
return AgentJobResponse(job=job)
|
||||||
|
|
||||||
|
|
||||||
|
def list_agent_jobs(repository: object) -> AgentJobListResponse:
|
||||||
|
jobs = [_project_admin_view(repository, job) for job in repository.agent_jobs.list()]
|
||||||
|
return AgentJobListResponse(jobs=jobs)
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_job(repository: object, job_id: UUID) -> AgentJobResponse:
|
||||||
|
job = repository.agent_jobs.get(job_id)
|
||||||
|
return AgentJobResponse(job=_project_admin_view(repository, job))
|
||||||
|
|
||||||
|
|
||||||
|
def retry_agent_job(
|
||||||
|
repository: object,
|
||||||
|
job_id: UUID,
|
||||||
|
*,
|
||||||
|
actor_user_id: UUID | None = None,
|
||||||
|
) -> AgentJobResponse:
|
||||||
|
parent = repository.agent_jobs.get(job_id)
|
||||||
|
retry_eligible, retry_block_reason = evaluate_retry_policy(repository, parent)
|
||||||
|
if not retry_eligible:
|
||||||
|
raise PermissionError(retry_block_reason or "Retry is blocked for this job.")
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
retry = repository.agent_jobs.create(
|
||||||
|
article_id=parent.article_id,
|
||||||
|
parent_job_id=parent.id,
|
||||||
|
attempt=parent.attempt + 1,
|
||||||
|
job_type=parent.job_type,
|
||||||
|
agent_profile=parent.agent_profile,
|
||||||
|
status=AgentJobStatus.QUEUED,
|
||||||
|
input_files=[file_ref.model_dump(mode="json") for file_ref in parent.input_files],
|
||||||
|
payload=parent.payload,
|
||||||
|
queued_at=now,
|
||||||
|
)
|
||||||
|
_record_job_event(
|
||||||
|
repository,
|
||||||
|
job=retry,
|
||||||
|
event_type="AGENT_JOB_RETRIED",
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
created_at=now,
|
||||||
|
payload={
|
||||||
|
"parent_job_id": str(parent.id),
|
||||||
|
"attempt": retry.attempt,
|
||||||
|
"job_type": retry.job_type.value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if should_auto_retry_section_job(retry):
|
||||||
|
completion = build_demo_section_completion_payload(
|
||||||
|
payload=retry.payload,
|
||||||
|
failed=False,
|
||||||
|
)
|
||||||
|
return complete_agent_job(
|
||||||
|
repository,
|
||||||
|
job_id=retry.id,
|
||||||
|
workspace_path=f"/tmp/demo/{retry.id}",
|
||||||
|
stdout="deterministic demo section retry succeeded\n",
|
||||||
|
stderr="",
|
||||||
|
exit_code=0,
|
||||||
|
duration_ms=8,
|
||||||
|
output=completion,
|
||||||
|
)
|
||||||
|
return AgentJobResponse(job=_project_admin_view(repository, retry))
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_agent_job(
|
||||||
|
repository: object,
|
||||||
|
job_id: UUID,
|
||||||
|
*,
|
||||||
|
actor_user_id: UUID | None = None,
|
||||||
|
) -> AgentJobResponse:
|
||||||
|
now = _now()
|
||||||
|
existing = repository.agent_jobs.get(job_id)
|
||||||
|
job = repository.agent_jobs.cancel(job_id=job_id, finished_at=now)
|
||||||
|
if existing.status != job.status and job.status == AgentJobStatus.CANCELLED:
|
||||||
|
_record_job_event(
|
||||||
|
repository,
|
||||||
|
job=job,
|
||||||
|
event_type="AGENT_JOB_CANCELLED",
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
created_at=now,
|
||||||
|
payload={"job_type": job.job_type.value, "attempt": job.attempt},
|
||||||
|
)
|
||||||
|
return AgentJobResponse(job=_project_admin_view(repository, job))
|
||||||
|
|
||||||
|
|
||||||
|
def claim_next_agent_job(repository: object) -> AgentJobResponse | None:
|
||||||
|
job = repository.agent_jobs.claim_next_queued(started_at=_now())
|
||||||
|
if job is None:
|
||||||
|
return None
|
||||||
|
return AgentJobResponse(job=job)
|
||||||
|
|
||||||
|
|
||||||
|
def complete_agent_job(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
job_id: UUID,
|
||||||
|
workspace_path: str | None,
|
||||||
|
stdout: str,
|
||||||
|
stderr: str,
|
||||||
|
exit_code: int | None,
|
||||||
|
duration_ms: int | None,
|
||||||
|
output: dict[str, Any],
|
||||||
|
) -> AgentJobResponse:
|
||||||
|
existing_job = repository.agent_jobs.get(job_id)
|
||||||
|
try:
|
||||||
|
validated_output = AgentJobOutput.model_validate(output)
|
||||||
|
except ValidationError as error:
|
||||||
|
finished_at = _now()
|
||||||
|
job = repository.agent_jobs.complete(
|
||||||
|
job_id=job_id,
|
||||||
|
status=AgentJobStatus.FAILED,
|
||||||
|
workspace_path=workspace_path,
|
||||||
|
output_files=[],
|
||||||
|
payload=existing_job.payload,
|
||||||
|
error_category=AgentJobErrorCategory.FAILED_SCHEMA_VALIDATION,
|
||||||
|
error_message=str(error),
|
||||||
|
stdout=stdout,
|
||||||
|
stderr=stderr,
|
||||||
|
exit_code=exit_code,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
finished_at=finished_at,
|
||||||
|
)
|
||||||
|
_record_completion_event(
|
||||||
|
repository,
|
||||||
|
previous=existing_job,
|
||||||
|
current=job,
|
||||||
|
created_at=finished_at,
|
||||||
|
)
|
||||||
|
return AgentJobResponse(job=_project_admin_view(repository, job))
|
||||||
|
|
||||||
|
merged_payload = _merge_payload(
|
||||||
|
base_payload=existing_job.payload,
|
||||||
|
new_payload=validated_output.payload,
|
||||||
|
)
|
||||||
|
status = _normalize_completion_status(
|
||||||
|
status=validated_output.status,
|
||||||
|
exit_code=exit_code,
|
||||||
|
error_category=validated_output.error_category,
|
||||||
|
)
|
||||||
|
error_category = _normalize_error_category(
|
||||||
|
initial=validated_output.error_category,
|
||||||
|
status=validated_output.status,
|
||||||
|
exit_code=exit_code,
|
||||||
|
)
|
||||||
|
error_message = validated_output.error_message
|
||||||
|
unsupported_claims = _extract_unsupported_claims(merged_payload)
|
||||||
|
if existing_job.job_type == AgentJobType.SECTION_SCAFFOLD and unsupported_claims:
|
||||||
|
status = AgentJobStatus.FAILED
|
||||||
|
error_category = AgentJobErrorCategory.UNSUPPORTED_CLAIMS_FOUND
|
||||||
|
if not error_message:
|
||||||
|
error_message = (
|
||||||
|
f"Unsupported claims introduced during scaffolding: {len(unsupported_claims)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
finished_at = _now()
|
||||||
|
job = repository.agent_jobs.complete(
|
||||||
|
job_id=job_id,
|
||||||
|
status=status,
|
||||||
|
workspace_path=workspace_path,
|
||||||
|
output_files=[
|
||||||
|
file_ref.model_dump(mode="json") for file_ref in validated_output.output_files
|
||||||
|
],
|
||||||
|
payload=merged_payload,
|
||||||
|
error_category=error_category,
|
||||||
|
error_message=error_message,
|
||||||
|
stdout=stdout,
|
||||||
|
stderr=stderr,
|
||||||
|
exit_code=exit_code,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
finished_at=finished_at,
|
||||||
|
)
|
||||||
|
_record_completion_event(
|
||||||
|
repository,
|
||||||
|
previous=existing_job,
|
||||||
|
current=job,
|
||||||
|
created_at=finished_at,
|
||||||
|
)
|
||||||
|
return AgentJobResponse(job=_project_admin_view(repository, job))
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_completion_status(
|
||||||
|
*,
|
||||||
|
status: AgentJobStatus,
|
||||||
|
exit_code: int | None,
|
||||||
|
error_category: AgentJobErrorCategory | None,
|
||||||
|
) -> AgentJobStatus:
|
||||||
|
if status == AgentJobStatus.SUCCEEDED and exit_code not in (0, None):
|
||||||
|
return AgentJobStatus.FAILED
|
||||||
|
if (
|
||||||
|
status == AgentJobStatus.SUCCEEDED
|
||||||
|
and error_category == AgentJobErrorCategory.CLI_EXIT_CODE_FAILURE
|
||||||
|
and exit_code in (
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return AgentJobStatus.SUCCEEDED
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_error_category(
|
||||||
|
*,
|
||||||
|
initial: AgentJobErrorCategory | None,
|
||||||
|
status: AgentJobStatus,
|
||||||
|
exit_code: int | None,
|
||||||
|
) -> AgentJobErrorCategory | None:
|
||||||
|
if initial is not None:
|
||||||
|
return initial
|
||||||
|
if status == AgentJobStatus.SUCCEEDED and exit_code not in (0, None):
|
||||||
|
return AgentJobErrorCategory.CLI_EXIT_CODE_FAILURE
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_payload(
|
||||||
|
*,
|
||||||
|
base_payload: dict[str, Any],
|
||||||
|
new_payload: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
merged = dict(base_payload)
|
||||||
|
for key, value in new_payload.items():
|
||||||
|
merged[key] = value
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_unsupported_claims(payload: dict[str, Any]) -> list[Any]:
|
||||||
|
claims = payload.get("unsupported_claims")
|
||||||
|
if isinstance(claims, list):
|
||||||
|
return claims
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _project_admin_view(repository: object, job: AgentJobSummary) -> AgentJobSummary:
|
||||||
|
return project_job_for_view(repository, job, viewer_role=Role.ADMIN)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_completion_event(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
previous: AgentJobSummary,
|
||||||
|
current: AgentJobSummary,
|
||||||
|
created_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
if current.article_id is None:
|
||||||
|
return
|
||||||
|
if previous.status == current.status:
|
||||||
|
return
|
||||||
|
|
||||||
|
if current.status == AgentJobStatus.FAILED:
|
||||||
|
retry_eligible, _ = evaluate_retry_policy(repository, current)
|
||||||
|
_record_job_event(
|
||||||
|
repository,
|
||||||
|
job=current,
|
||||||
|
event_type="AGENT_JOB_FAILED",
|
||||||
|
actor_user_id=None,
|
||||||
|
created_at=created_at,
|
||||||
|
payload={
|
||||||
|
"job_type": current.job_type.value,
|
||||||
|
"attempt": current.attempt,
|
||||||
|
"error_category": (
|
||||||
|
current.error_category.value if current.error_category is not None else None
|
||||||
|
),
|
||||||
|
"error_message": current.error_message,
|
||||||
|
"retry_eligible": retry_eligible,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if current.status == AgentJobStatus.SUCCEEDED:
|
||||||
|
_record_job_event(
|
||||||
|
repository,
|
||||||
|
job=current,
|
||||||
|
event_type="AGENT_JOB_SUCCEEDED",
|
||||||
|
actor_user_id=None,
|
||||||
|
created_at=created_at,
|
||||||
|
payload={
|
||||||
|
"job_type": current.job_type.value,
|
||||||
|
"attempt": current.attempt,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_job_event(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
job: AgentJobSummary,
|
||||||
|
event_type: str,
|
||||||
|
actor_user_id: UUID | None,
|
||||||
|
created_at: datetime,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
if job.article_id is None:
|
||||||
|
return
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=job.article_id,
|
||||||
|
event_type=event_type,
|
||||||
|
from_status=None,
|
||||||
|
to_status=None,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
payload={"job_id": str(job.id), **payload},
|
||||||
|
created_at=created_at,
|
||||||
|
)
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from src.domain.contracts import (
|
||||||
|
ArticleCreateRequest,
|
||||||
|
ArticleCreateResponse,
|
||||||
|
ArticleDetailResponse,
|
||||||
|
ArticleListResponse,
|
||||||
|
ArticleWorkflowStatus,
|
||||||
|
CurrentUser,
|
||||||
|
PublishingStatus,
|
||||||
|
WorkflowTemplateSnapshot,
|
||||||
|
WorkflowTemplateStageSnapshot,
|
||||||
|
WorkflowTemplateStatus,
|
||||||
|
WorkflowTemplateSummary,
|
||||||
|
)
|
||||||
|
from src.application.observability import (
|
||||||
|
build_observability_timeline,
|
||||||
|
project_job_for_view,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_article(
|
||||||
|
repository: object,
|
||||||
|
request: ArticleCreateRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> ArticleCreateResponse:
|
||||||
|
repository.target_sites.get_by_id(request.target_site_id)
|
||||||
|
workflow_snapshot = _resolve_workflow_template_snapshot(repository, request)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
summary = repository.articles.create(
|
||||||
|
target_site_id=request.target_site_id,
|
||||||
|
workflow_template_id=workflow_snapshot.id if workflow_snapshot else None,
|
||||||
|
workflow_template_version=workflow_snapshot.version if workflow_snapshot else None,
|
||||||
|
workflow_template_snapshot=(
|
||||||
|
workflow_snapshot.model_dump(mode="json") if workflow_snapshot else None
|
||||||
|
),
|
||||||
|
status=ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
|
||||||
|
publishing_status=PublishingStatus.PUBLISH_NOT_STARTED,
|
||||||
|
brief_description=request.brief_description,
|
||||||
|
working_title=request.working_title,
|
||||||
|
language=request.language,
|
||||||
|
content_type=request.content_type,
|
||||||
|
primary_keyword=request.primary_keyword,
|
||||||
|
assigned_editor_id=request.assigned_editor_id or current_user.id,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=summary.id,
|
||||||
|
event_type="ARTICLE_CREATED",
|
||||||
|
from_status=None,
|
||||||
|
to_status=ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={"brief_description_length": len(request.brief_description)},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return ArticleCreateResponse(article=summary)
|
||||||
|
|
||||||
|
|
||||||
|
def list_articles(repository: object) -> ArticleListResponse:
|
||||||
|
return ArticleListResponse(articles=repository.articles.list())
|
||||||
|
|
||||||
|
|
||||||
|
def get_article_detail(
|
||||||
|
repository: object,
|
||||||
|
article_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> ArticleDetailResponse:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
target_site = repository.target_sites.get_by_id(article.target_site_id)
|
||||||
|
workflow_template_snapshot = repository.articles.get_workflow_template_snapshot(
|
||||||
|
article_id
|
||||||
|
)
|
||||||
|
workflow_events = repository.articles.list_workflow_events(article_id)
|
||||||
|
boundary_questions = repository.boundary_questions.list_for_article(article_id)
|
||||||
|
plans = repository.article_plans.list_for_article(article_id)
|
||||||
|
draft = repository.article_drafts.latest_for_article(article_id)
|
||||||
|
research_manifests = repository.research_manifests.list_for_article(article_id)
|
||||||
|
evidence = repository.evidence_items.list_for_article(article_id)
|
||||||
|
claims = repository.claims.list_for_article(article_id)
|
||||||
|
assets = repository.assets.list_for_article(article_id)
|
||||||
|
agent_jobs = repository.agent_jobs.list_for_article(article_id)
|
||||||
|
projected_jobs = [
|
||||||
|
project_job_for_view(
|
||||||
|
repository,
|
||||||
|
job,
|
||||||
|
viewer_role=current_user.role,
|
||||||
|
)
|
||||||
|
for job in agent_jobs
|
||||||
|
]
|
||||||
|
timeline = build_observability_timeline(workflow_events, projected_jobs)
|
||||||
|
publish_commit = repository.publish_commits.latest_for_article(article_id)
|
||||||
|
return ArticleDetailResponse(
|
||||||
|
article=article,
|
||||||
|
target_site=target_site,
|
||||||
|
workflow_template_snapshot=workflow_template_snapshot,
|
||||||
|
workflow_events=workflow_events,
|
||||||
|
boundary_questions=boundary_questions,
|
||||||
|
plan=plans[-1] if plans else None,
|
||||||
|
draft=draft,
|
||||||
|
evidence=evidence,
|
||||||
|
claims=claims,
|
||||||
|
assets=assets,
|
||||||
|
agent_jobs=projected_jobs,
|
||||||
|
timeline=timeline,
|
||||||
|
research_manifests=research_manifests,
|
||||||
|
publish_commit=publish_commit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_workflow_template_snapshot(
|
||||||
|
repository: object,
|
||||||
|
request: ArticleCreateRequest,
|
||||||
|
) -> WorkflowTemplateSnapshot | None:
|
||||||
|
if request.workflow_template_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
workflow = repository.workflow_templates.get(request.workflow_template_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise ValueError("Invalid workflow_template_id") from error
|
||||||
|
|
||||||
|
if workflow.status != WorkflowTemplateStatus.ACTIVE:
|
||||||
|
raise ValueError("Workflow template must be active")
|
||||||
|
|
||||||
|
return _workflow_template_snapshot(workflow)
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_template_snapshot(
|
||||||
|
workflow: WorkflowTemplateSummary,
|
||||||
|
) -> WorkflowTemplateSnapshot:
|
||||||
|
return WorkflowTemplateSnapshot(
|
||||||
|
id=workflow.id,
|
||||||
|
name=workflow.name,
|
||||||
|
slug=workflow.slug,
|
||||||
|
version=workflow.version,
|
||||||
|
stage_summary=[
|
||||||
|
WorkflowTemplateStageSnapshot(
|
||||||
|
id=stage.id,
|
||||||
|
stable_key=stage.stable_key,
|
||||||
|
display_name=stage.display_name,
|
||||||
|
position=stage.position,
|
||||||
|
owner_role=stage.owner_role,
|
||||||
|
runner_profile_key=stage.runner_profile_key,
|
||||||
|
requires_human_approval=stage.requires_human_approval,
|
||||||
|
)
|
||||||
|
for stage in workflow.stages
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from src.domain.contracts import (
|
||||||
|
AssetGenerateSpecsResponse,
|
||||||
|
AssetListResponse,
|
||||||
|
AssetResponse,
|
||||||
|
AssetStatus,
|
||||||
|
AssetSummary,
|
||||||
|
AssetType,
|
||||||
|
AssetUpdateRequest,
|
||||||
|
AssetUploadResponse,
|
||||||
|
CurrentUser,
|
||||||
|
PlanReviewStatus,
|
||||||
|
)
|
||||||
|
from src.infrastructure.object_storage import ObjectStorageClient
|
||||||
|
|
||||||
|
|
||||||
|
def generate_asset_specs(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> AssetGenerateSpecsResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
plan = _approved_plan(repository, article_id=article_id)
|
||||||
|
if plan is None or not plan.sections:
|
||||||
|
raise PermissionError("Approved plan with sections is required before asset specs")
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
created_assets: list[AssetSummary] = []
|
||||||
|
for index, section in enumerate(plan.sections, start=1):
|
||||||
|
asset_type = _asset_type_for_section(index)
|
||||||
|
title = f"{section.heading} ({asset_type.value})"
|
||||||
|
asset = repository.assets.create(
|
||||||
|
article_id=article_id,
|
||||||
|
section_id=section.id,
|
||||||
|
asset_type=asset_type,
|
||||||
|
title=title,
|
||||||
|
prompt=(
|
||||||
|
"Create an editorial-ready visual for section "
|
||||||
|
f"'{section.heading}' with evidence-backed details."
|
||||||
|
),
|
||||||
|
object_key=None,
|
||||||
|
file_url=None,
|
||||||
|
alt_text=f"Visual for {section.heading}",
|
||||||
|
caption=f"Supporting visual for section: {section.heading}",
|
||||||
|
status=AssetStatus.GENERATED,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
revision_index = repository.assets.next_revision_index(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset.id,
|
||||||
|
)
|
||||||
|
repository.assets.create_revision(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset.id,
|
||||||
|
revision_index=revision_index,
|
||||||
|
action="SPEC_GENERATED",
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={
|
||||||
|
"section_id": str(section.id),
|
||||||
|
"asset_type": asset.asset_type.value,
|
||||||
|
"title": asset.title,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
created_assets.append(
|
||||||
|
repository.assets.get(article_id=article_id, asset_id=asset.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article_id,
|
||||||
|
event_type="ASSET_SPECS_GENERATED",
|
||||||
|
from_status=None,
|
||||||
|
to_status=None,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={
|
||||||
|
"count": len(created_assets),
|
||||||
|
"asset_ids": [str(asset.id) for asset in created_assets],
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return AssetGenerateSpecsResponse(assets=created_assets)
|
||||||
|
|
||||||
|
|
||||||
|
def list_assets(repository: object, *, article_id: UUID) -> AssetListResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
return AssetListResponse(assets=repository.assets.list_for_article(article_id))
|
||||||
|
|
||||||
|
|
||||||
|
def update_asset(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
asset_id: UUID,
|
||||||
|
request: AssetUpdateRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> AssetResponse:
|
||||||
|
current = repository.assets.get(article_id=article_id, asset_id=asset_id)
|
||||||
|
next_section_id = request.section_id if request.section_id is not None else current.section_id
|
||||||
|
next_title = request.title if request.title is not None else current.title
|
||||||
|
next_prompt = request.prompt if request.prompt is not None else current.prompt
|
||||||
|
next_alt_text = request.alt_text if request.alt_text is not None else current.alt_text
|
||||||
|
next_caption = request.caption if request.caption is not None else current.caption
|
||||||
|
next_status = request.status if request.status is not None else current.status
|
||||||
|
changed_fields = _changed_fields(
|
||||||
|
current=current,
|
||||||
|
section_id=next_section_id,
|
||||||
|
title=next_title,
|
||||||
|
prompt=next_prompt,
|
||||||
|
alt_text=next_alt_text,
|
||||||
|
caption=next_caption,
|
||||||
|
status=next_status,
|
||||||
|
)
|
||||||
|
if not changed_fields:
|
||||||
|
return AssetResponse(asset=current)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
repository.assets.update(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
section_id=next_section_id,
|
||||||
|
title=next_title,
|
||||||
|
prompt=next_prompt,
|
||||||
|
object_key=current.object_key,
|
||||||
|
file_url=current.file_url,
|
||||||
|
alt_text=next_alt_text,
|
||||||
|
caption=next_caption,
|
||||||
|
status=next_status,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
revision_index = repository.assets.next_revision_index(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
)
|
||||||
|
repository.assets.create_revision(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
revision_index=revision_index,
|
||||||
|
action="ASSET_UPDATED",
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={
|
||||||
|
"changed_fields": changed_fields,
|
||||||
|
"status": next_status.value,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
if current.status != next_status:
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article_id,
|
||||||
|
event_type="ASSET_STATUS_UPDATED",
|
||||||
|
from_status=None,
|
||||||
|
to_status=None,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={
|
||||||
|
"asset_id": str(asset_id),
|
||||||
|
"from_status": current.status.value,
|
||||||
|
"to_status": next_status.value,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
refreshed = repository.assets.get(article_id=article_id, asset_id=asset_id)
|
||||||
|
return AssetResponse(asset=refreshed)
|
||||||
|
|
||||||
|
|
||||||
|
def approve_asset(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
asset_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> AssetResponse:
|
||||||
|
current = repository.assets.get(article_id=article_id, asset_id=asset_id)
|
||||||
|
if current.status == AssetStatus.APPROVED:
|
||||||
|
return AssetResponse(asset=current)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
repository.assets.update(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
section_id=current.section_id,
|
||||||
|
title=current.title,
|
||||||
|
prompt=current.prompt,
|
||||||
|
object_key=current.object_key,
|
||||||
|
file_url=current.file_url,
|
||||||
|
alt_text=current.alt_text,
|
||||||
|
caption=current.caption,
|
||||||
|
status=AssetStatus.APPROVED,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
approved = repository.assets.get(article_id=article_id, asset_id=asset_id)
|
||||||
|
revision_index = repository.assets.next_revision_index(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
)
|
||||||
|
repository.assets.create_revision(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
revision_index=revision_index,
|
||||||
|
action="ASSET_APPROVED",
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={
|
||||||
|
"from_status": current.status.value,
|
||||||
|
"to_status": AssetStatus.APPROVED.value,
|
||||||
|
"object_key": approved.object_key,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article_id,
|
||||||
|
event_type="ASSET_APPROVED",
|
||||||
|
from_status=None,
|
||||||
|
to_status=None,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={
|
||||||
|
"asset_id": str(asset_id),
|
||||||
|
"from_status": current.status.value,
|
||||||
|
"to_status": AssetStatus.APPROVED.value,
|
||||||
|
"object_key": approved.object_key,
|
||||||
|
"file_url": approved.file_url,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
refreshed = repository.assets.get(article_id=article_id, asset_id=asset_id)
|
||||||
|
return AssetResponse(asset=refreshed)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_asset_file(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
asset_id: UUID,
|
||||||
|
filename: str,
|
||||||
|
content_type: str | None,
|
||||||
|
content: bytes,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
object_storage: ObjectStorageClient,
|
||||||
|
) -> AssetUploadResponse:
|
||||||
|
current = repository.assets.get(article_id=article_id, asset_id=asset_id)
|
||||||
|
now = _now()
|
||||||
|
revision_index = repository.assets.next_revision_index(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
)
|
||||||
|
safe_filename = _safe_filename(filename)
|
||||||
|
object_key = f"articles/{article_id}/assets/{asset_id}/r{revision_index}/{safe_filename}"
|
||||||
|
file_url = object_storage.put_bytes(
|
||||||
|
object_key=object_key,
|
||||||
|
content=content,
|
||||||
|
content_type=content_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
had_previous_file = bool(current.object_key or current.file_url)
|
||||||
|
repository.assets.update(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
section_id=current.section_id,
|
||||||
|
title=current.title,
|
||||||
|
prompt=current.prompt,
|
||||||
|
object_key=object_key,
|
||||||
|
file_url=file_url,
|
||||||
|
alt_text=current.alt_text,
|
||||||
|
caption=current.caption,
|
||||||
|
status=AssetStatus.PENDING,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
action = "ASSET_FILE_REPLACED" if had_previous_file else "ASSET_FILE_UPLOADED"
|
||||||
|
repository.assets.create_revision(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
revision_index=revision_index,
|
||||||
|
action=action,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={
|
||||||
|
"previous_object_key": current.object_key,
|
||||||
|
"previous_file_url": current.file_url,
|
||||||
|
"next_object_key": object_key,
|
||||||
|
"next_file_url": file_url,
|
||||||
|
"content_type": content_type or "application/octet-stream",
|
||||||
|
"filename": safe_filename,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article_id,
|
||||||
|
event_type=action,
|
||||||
|
from_status=None,
|
||||||
|
to_status=None,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={
|
||||||
|
"asset_id": str(asset_id),
|
||||||
|
"previous_object_key": current.object_key,
|
||||||
|
"next_object_key": object_key,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
refreshed = repository.assets.get(article_id=article_id, asset_id=asset_id)
|
||||||
|
return AssetUploadResponse(asset=refreshed)
|
||||||
|
|
||||||
|
|
||||||
|
def _approved_plan(repository: object, *, article_id: UUID) -> object | None:
|
||||||
|
plans = repository.article_plans.list_for_article(article_id)
|
||||||
|
approved_plans = [plan for plan in plans if plan.status == PlanReviewStatus.APPROVED]
|
||||||
|
if not approved_plans:
|
||||||
|
return None
|
||||||
|
approved_plans.sort(key=lambda plan: plan.version)
|
||||||
|
return approved_plans[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_type_for_section(index: int) -> AssetType:
|
||||||
|
ordered_types = [
|
||||||
|
AssetType.HERO_IMAGE,
|
||||||
|
AssetType.ARCHITECTURE_DIAGRAM,
|
||||||
|
AssetType.DIAGRAM,
|
||||||
|
AssetType.TABLE,
|
||||||
|
AssetType.FLOWCHART,
|
||||||
|
AssetType.COMPARISON_MATRIX,
|
||||||
|
AssetType.INLINE_IMAGE,
|
||||||
|
]
|
||||||
|
return ordered_types[(index - 1) % len(ordered_types)]
|
||||||
|
|
||||||
|
|
||||||
|
def _changed_fields(
|
||||||
|
*,
|
||||||
|
current: AssetSummary,
|
||||||
|
section_id: UUID | None,
|
||||||
|
title: str,
|
||||||
|
prompt: str | None,
|
||||||
|
alt_text: str | None,
|
||||||
|
caption: str | None,
|
||||||
|
status: AssetStatus,
|
||||||
|
) -> list[str]:
|
||||||
|
changed: list[str] = []
|
||||||
|
if current.section_id != section_id:
|
||||||
|
changed.append("section_id")
|
||||||
|
if current.title != title:
|
||||||
|
changed.append("title")
|
||||||
|
if current.prompt != prompt:
|
||||||
|
changed.append("prompt")
|
||||||
|
if current.alt_text != alt_text:
|
||||||
|
changed.append("alt_text")
|
||||||
|
if current.caption != caption:
|
||||||
|
changed.append("caption")
|
||||||
|
if current.status != status:
|
||||||
|
changed.append("status")
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(value: str) -> str:
|
||||||
|
filename = Path(value).name.strip()
|
||||||
|
if not filename:
|
||||||
|
return "asset.bin"
|
||||||
|
return filename.replace("/", "_")
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.domain.contracts import CurrentUser
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user_by_email(repository: object, email: str) -> CurrentUser:
|
||||||
|
user = repository.users.get_by_email(email)
|
||||||
|
return CurrentUser(
|
||||||
|
id=user.id,
|
||||||
|
email=email,
|
||||||
|
display_name=user.display_name,
|
||||||
|
role=user.role,
|
||||||
|
)
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from src.domain.contracts import (
|
||||||
|
AgentJobErrorCategory,
|
||||||
|
AgentJobListResponse,
|
||||||
|
AgentJobStatus,
|
||||||
|
AgentJobType,
|
||||||
|
ArticleSummary,
|
||||||
|
ArticleWorkflowStatus,
|
||||||
|
BoundaryQuestionListResponse,
|
||||||
|
BoundaryQuestionResponse,
|
||||||
|
BoundaryQuestionUpdateRequest,
|
||||||
|
CurrentUser,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BOUNDARY_QUESTION_FIXTURE: tuple[dict[str, object], ...] = (
|
||||||
|
{
|
||||||
|
"category": "audience",
|
||||||
|
"label": "Audience",
|
||||||
|
"is_required": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "purpose",
|
||||||
|
"label": "Purpose",
|
||||||
|
"is_required": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "reader_outcome",
|
||||||
|
"label": "Reader outcome",
|
||||||
|
"is_required": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "depth",
|
||||||
|
"label": "Depth",
|
||||||
|
"is_required": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "tone",
|
||||||
|
"label": "Tone",
|
||||||
|
"is_required": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "excluded_topics",
|
||||||
|
"label": "Excluded topics",
|
||||||
|
"is_required": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "primary_keyword",
|
||||||
|
"label": "Primary keyword",
|
||||||
|
"is_required": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "competitor_angle",
|
||||||
|
"label": "Competitor angle",
|
||||||
|
"is_required": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "evidence_standard",
|
||||||
|
"label": "Evidence standard",
|
||||||
|
"is_required": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "visual_expectations",
|
||||||
|
"label": "Visual expectations",
|
||||||
|
"is_required": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_boundary_questions(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> BoundaryQuestionListResponse:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
target_site = repository.target_sites.get_by_id(article.target_site_id)
|
||||||
|
now = _now()
|
||||||
|
previous_status = article.status
|
||||||
|
|
||||||
|
job = repository.agent_jobs.create(
|
||||||
|
article_id=article_id,
|
||||||
|
parent_job_id=None,
|
||||||
|
attempt=1,
|
||||||
|
job_type=AgentJobType.BOUNDARY_QUESTIONS,
|
||||||
|
agent_profile="fake-boundary-questions",
|
||||||
|
status=AgentJobStatus.QUEUED,
|
||||||
|
input_files=[
|
||||||
|
{
|
||||||
|
"path": "inputs/article-brief.json",
|
||||||
|
"content_hash": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "inputs/target-site-config.json",
|
||||||
|
"content_hash": None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queued_at=now,
|
||||||
|
)
|
||||||
|
job = repository.agent_jobs.complete(
|
||||||
|
job_id=job.id,
|
||||||
|
status=AgentJobStatus.SUCCEEDED,
|
||||||
|
workspace_path=None,
|
||||||
|
output_files=[{"path": "outputs/boundary-questions.json"}],
|
||||||
|
payload={},
|
||||||
|
error_category=None,
|
||||||
|
error_message=None,
|
||||||
|
stdout="fake boundary question fixture generated\n",
|
||||||
|
stderr="",
|
||||||
|
exit_code=0,
|
||||||
|
duration_ms=0,
|
||||||
|
finished_at=now,
|
||||||
|
)
|
||||||
|
questions = repository.boundary_questions.replace_for_article(
|
||||||
|
article_id=article_id,
|
||||||
|
questions=_build_questions(article=article, target_site=target_site),
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
article = repository.articles.update_status(
|
||||||
|
article_id=article_id,
|
||||||
|
status=ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article.id,
|
||||||
|
event_type="BOUNDARY_QUESTIONS_GENERATED",
|
||||||
|
from_status=previous_status,
|
||||||
|
to_status=ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={"agent_job_id": str(job.id), "question_count": len(questions)},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
return BoundaryQuestionListResponse(questions=questions, job=job)
|
||||||
|
|
||||||
|
|
||||||
|
def list_boundary_questions(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
) -> BoundaryQuestionListResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
return BoundaryQuestionListResponse(
|
||||||
|
questions=repository.boundary_questions.list_for_article(article_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_boundary_question(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
question_id: UUID,
|
||||||
|
request: BoundaryQuestionUpdateRequest,
|
||||||
|
) -> BoundaryQuestionResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
answer = request.answer.strip() if request.answer is not None else None
|
||||||
|
question = repository.boundary_questions.update_answer(
|
||||||
|
article_id=article_id,
|
||||||
|
question_id=question_id,
|
||||||
|
answer=answer or None,
|
||||||
|
updated_at=_now(),
|
||||||
|
)
|
||||||
|
return BoundaryQuestionResponse(question=question)
|
||||||
|
|
||||||
|
|
||||||
|
def submit_boundary_questions(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> ArticleSummary:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
questions = repository.boundary_questions.list_for_article(article_id)
|
||||||
|
if not questions:
|
||||||
|
raise ValueError("Boundary questions must be generated before submission")
|
||||||
|
|
||||||
|
missing_required = [
|
||||||
|
question.category
|
||||||
|
for question in questions
|
||||||
|
if question.is_required and not (question.answer or "").strip()
|
||||||
|
]
|
||||||
|
if missing_required:
|
||||||
|
raise ValueError(
|
||||||
|
"All required boundary questions must be answered: "
|
||||||
|
+ ", ".join(missing_required)
|
||||||
|
)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
updated_article = repository.articles.update_status(
|
||||||
|
article_id=article_id,
|
||||||
|
status=ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article_id,
|
||||||
|
event_type="BOUNDARY_ANSWERS_SUBMITTED",
|
||||||
|
from_status=article.status,
|
||||||
|
to_status=ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={"required_answer_count": len(questions) - len(missing_required)},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return updated_article
|
||||||
|
|
||||||
|
|
||||||
|
def create_plan_generation_job(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
) -> AgentJobListResponse:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
if article.status != ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED:
|
||||||
|
raise PermissionError("Boundary answers must be submitted before plan generation")
|
||||||
|
|
||||||
|
job = repository.agent_jobs.create(
|
||||||
|
article_id=article_id,
|
||||||
|
parent_job_id=None,
|
||||||
|
attempt=1,
|
||||||
|
job_type=AgentJobType.PLAN_GENERATION,
|
||||||
|
agent_profile="fake-plan-generation",
|
||||||
|
status=AgentJobStatus.QUEUED,
|
||||||
|
input_files=[{"path": "inputs/boundary-answers.json", "content_hash": None}],
|
||||||
|
queued_at=_now(),
|
||||||
|
)
|
||||||
|
return AgentJobListResponse(jobs=[job])
|
||||||
|
|
||||||
|
|
||||||
|
def _build_questions(*, article: ArticleSummary, target_site: object) -> list[dict[str, object]]:
|
||||||
|
title = article.working_title or article.primary_keyword or article.content_type
|
||||||
|
site_audience = getattr(target_site, "audience")
|
||||||
|
brand_voice = getattr(target_site, "brand_voice")
|
||||||
|
primary_keyword = article.primary_keyword or "the primary topic"
|
||||||
|
brief = article.brief_description
|
||||||
|
|
||||||
|
prompts = {
|
||||||
|
"audience": f"Who exactly is this article for on {site_audience}?",
|
||||||
|
"purpose": f"What business or editorial purpose should '{title}' serve?",
|
||||||
|
"reader_outcome": "What should the reader be able to decide or do after reading?",
|
||||||
|
"depth": f"How deep should coverage go for this {article.content_type} brief?",
|
||||||
|
"tone": f"What tone should adapt the site voice '{brand_voice}' for this brief?",
|
||||||
|
"excluded_topics": "Which topics, claims, or comparisons must be excluded?",
|
||||||
|
"primary_keyword": f"How should the keyword '{primary_keyword}' be prioritized?",
|
||||||
|
"competitor_angle": "Which competitor angle should the article address or avoid?",
|
||||||
|
"evidence_standard": "What evidence standard is required for claims in this article?",
|
||||||
|
"visual_expectations": "What diagrams, tables, or images would clarify the brief?",
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"sort_order": index,
|
||||||
|
"category": str(item["category"]),
|
||||||
|
"question": f"{item['label']}: {prompts[str(item['category'])]} Brief: {brief}",
|
||||||
|
"answer": None,
|
||||||
|
"is_required": bool(item["is_required"]),
|
||||||
|
}
|
||||||
|
for index, item in enumerate(BOUNDARY_QUESTION_FIXTURE, start=1)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.domain.contracts import AgentJobSummary
|
||||||
|
|
||||||
|
|
||||||
|
_DEMO_MODE_ENV = "PIPELINE_DEMO_MODE"
|
||||||
|
_DEMO_REPOSITORY_ENV = "PIPELINE_DEMO_PUBLISH_REPO_PATH"
|
||||||
|
_DEFAULT_DEMO_REPOSITORY_PATH = "/tmp/pipeline-demo-site.git"
|
||||||
|
|
||||||
|
|
||||||
|
def is_demo_mode() -> bool:
|
||||||
|
value = os.environ.get(_DEMO_MODE_ENV, "").strip().lower()
|
||||||
|
return value in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_demo_repository_path() -> Path:
|
||||||
|
configured = os.environ.get(_DEMO_REPOSITORY_ENV, _DEFAULT_DEMO_REPOSITORY_PATH)
|
||||||
|
return Path(configured).expanduser().resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_demo_bare_repository(path: Path) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not path.exists():
|
||||||
|
_run_git(["init", "--bare", str(path)])
|
||||||
|
if _has_main_branch(path):
|
||||||
|
return
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="demo-site-seed-") as temp_dir:
|
||||||
|
worktree = Path(temp_dir) / "seed"
|
||||||
|
worktree.mkdir(parents=True, exist_ok=True)
|
||||||
|
_run_git(["init"], cwd=worktree)
|
||||||
|
_run_git(["config", "user.name", "Pipeline Demo Bot"], cwd=worktree)
|
||||||
|
_run_git(["config", "user.email", "pipeline-demo-bot@example.com"], cwd=worktree)
|
||||||
|
(worktree / "README.md").write_text("# Pipeline Demo Site\n", encoding="utf-8")
|
||||||
|
_run_git(["add", "README.md"], cwd=worktree)
|
||||||
|
_run_git(["commit", "-m", "seed demo repository"], cwd=worktree)
|
||||||
|
_run_git(["branch", "-M", "main"], cwd=worktree)
|
||||||
|
_run_git(["remote", "add", "origin", str(path)], cwd=worktree)
|
||||||
|
_run_git(["push", "origin", "main"], cwd=worktree)
|
||||||
|
|
||||||
|
|
||||||
|
def should_auto_run_section_jobs() -> bool:
|
||||||
|
return is_demo_mode()
|
||||||
|
|
||||||
|
|
||||||
|
def should_auto_retry_section_job(job: AgentJobSummary) -> bool:
|
||||||
|
if not is_demo_mode():
|
||||||
|
return False
|
||||||
|
if job.payload.get("demo_fail_once") is True and job.attempt > 1:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def build_demo_section_completion_payload(
|
||||||
|
*,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
failed: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
heading = _string_value(payload.get("heading")) or "Section"
|
||||||
|
section_id = _string_value(payload.get("section_id")) or "section"
|
||||||
|
draft_markdown = (
|
||||||
|
f"## {heading}\n\n"
|
||||||
|
f"This demo section is generated deterministically for `{section_id}`.\n\n"
|
||||||
|
"Read the related architecture guide: [internal reference](/guides/pipeline-demo).\n"
|
||||||
|
)
|
||||||
|
if failed:
|
||||||
|
return {
|
||||||
|
"status": "FAILED",
|
||||||
|
"error_category": "CLI_EXIT_CODE_FAILURE",
|
||||||
|
"error_message": "Deterministic demo failure for retry walkthrough.",
|
||||||
|
"payload": {
|
||||||
|
"section_id": section_id,
|
||||||
|
"heading": heading,
|
||||||
|
"artifact_label": heading,
|
||||||
|
"last_successful_step": "outline generated",
|
||||||
|
"unsupported_claims": [],
|
||||||
|
"draft_markdown": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": f"outputs/{section_id}.md"}],
|
||||||
|
"payload": {
|
||||||
|
"section_id": section_id,
|
||||||
|
"heading": heading,
|
||||||
|
"artifact_label": heading,
|
||||||
|
"unsupported_claims": [],
|
||||||
|
"last_successful_step": "section scaffold complete",
|
||||||
|
"suggested_visuals": [f"{heading} diagram"],
|
||||||
|
"draft_markdown": draft_markdown,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(value: str) -> str:
|
||||||
|
normalized = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-")
|
||||||
|
return normalized or "article"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_main_branch(path: Path) -> bool:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "--git-dir", str(path), "rev-parse", "--verify", "refs/heads/main"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git(command: list[str], *, cwd: Path | None = None) -> None:
|
||||||
|
subprocess.run(
|
||||||
|
["git", *command] if command[0] != "git" else command,
|
||||||
|
cwd=str(cwd) if cwd is not None else None,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _string_value(value: Any) -> str:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip()
|
||||||
|
return ""
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from src.domain.contracts import (
|
||||||
|
AgentJobStatus,
|
||||||
|
AgentJobSummary,
|
||||||
|
AgentJobType,
|
||||||
|
AssetStatus,
|
||||||
|
ArticleWorkflowStatus,
|
||||||
|
DraftAssembleResponse,
|
||||||
|
DraftFaqItem,
|
||||||
|
DraftListResponse,
|
||||||
|
DraftResponse,
|
||||||
|
DraftUpdateRequest,
|
||||||
|
PlanReviewStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assemble_draft(repository: object, *, article_id: UUID) -> DraftAssembleResponse:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
approved_plan = _approved_plan(repository, article_id=article_id)
|
||||||
|
if approved_plan is None or not approved_plan.sections:
|
||||||
|
raise PermissionError("Approved plan with sections is required before draft assembly")
|
||||||
|
|
||||||
|
section_jobs = repository.agent_jobs.list_for_article(article_id)
|
||||||
|
successful_jobs_by_section = _successful_jobs_by_section(section_jobs)
|
||||||
|
missing_section_headings = [
|
||||||
|
section.heading
|
||||||
|
for section in approved_plan.sections
|
||||||
|
if str(section.id) not in successful_jobs_by_section
|
||||||
|
]
|
||||||
|
if missing_section_headings:
|
||||||
|
raise ValueError(
|
||||||
|
"Missing successful section scaffolds for sections: "
|
||||||
|
+ ", ".join(missing_section_headings)
|
||||||
|
)
|
||||||
|
|
||||||
|
title = (
|
||||||
|
approved_plan.recommended_title
|
||||||
|
or article.working_title
|
||||||
|
or article.primary_keyword
|
||||||
|
or "Untitled draft"
|
||||||
|
)
|
||||||
|
meta_title = title
|
||||||
|
meta_description = _trim_description(article.brief_description or title)
|
||||||
|
|
||||||
|
section_blocks: list[str] = []
|
||||||
|
visual_placeholders: list[str] = []
|
||||||
|
evidence_references: list[str] = []
|
||||||
|
unsupported_warnings = _collect_job_warnings(section_jobs)
|
||||||
|
approved_assets_by_section = _approved_assets_by_section(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
for section in approved_plan.sections:
|
||||||
|
section_job = successful_jobs_by_section[str(section.id)]
|
||||||
|
payload = section_job.payload
|
||||||
|
section_blocks.append(_section_markdown(section.heading, payload))
|
||||||
|
visual_placeholders.extend(
|
||||||
|
_visual_placeholders_for_section(
|
||||||
|
section_id=section.id,
|
||||||
|
heading=section.heading,
|
||||||
|
payload=payload,
|
||||||
|
approved_assets=approved_assets_by_section.get(str(section.id), []),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
evidence_references.extend(_string_list(payload.get("used_evidence_ids")))
|
||||||
|
|
||||||
|
deduped_evidence_references = _dedupe_strings(evidence_references)
|
||||||
|
deduped_unsupported_warnings = _dedupe_strings(unsupported_warnings)
|
||||||
|
deduped_visual_placeholders = _dedupe_strings(visual_placeholders)
|
||||||
|
faq_items = _build_faq_items(
|
||||||
|
claims_to_prove=approved_plan.claims_to_prove,
|
||||||
|
keyword=article.primary_keyword,
|
||||||
|
)
|
||||||
|
|
||||||
|
draft_body = _assemble_body_markdown(
|
||||||
|
title=title,
|
||||||
|
meta_title=meta_title,
|
||||||
|
meta_description=meta_description,
|
||||||
|
section_blocks=section_blocks,
|
||||||
|
evidence_references=deduped_evidence_references,
|
||||||
|
visual_placeholders=deduped_visual_placeholders,
|
||||||
|
faq_items=faq_items,
|
||||||
|
unsupported_warnings=deduped_unsupported_warnings,
|
||||||
|
)
|
||||||
|
now = _now()
|
||||||
|
next_version = repository.article_drafts.latest_version(article_id) + 1
|
||||||
|
draft = repository.article_drafts.create_version(
|
||||||
|
article_id=article_id,
|
||||||
|
version=next_version,
|
||||||
|
title=title,
|
||||||
|
slug=_slugify(title),
|
||||||
|
meta_title=meta_title,
|
||||||
|
meta_description=meta_description,
|
||||||
|
body_object_key=f"articles/{article_id}/drafts/v{next_version}.md",
|
||||||
|
body_markdown=draft_body,
|
||||||
|
faq_items=[item.model_dump(mode="json") for item in faq_items],
|
||||||
|
visual_placeholders=deduped_visual_placeholders,
|
||||||
|
evidence_references=[UUID(item_id) for item_id in deduped_evidence_references],
|
||||||
|
unsupported_claim_warnings=deduped_unsupported_warnings,
|
||||||
|
based_on_draft_id=None,
|
||||||
|
status=ArticleWorkflowStatus.DRAFT_ASSEMBLED,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.articles.update_status(
|
||||||
|
article_id=article_id,
|
||||||
|
status=ArticleWorkflowStatus.DRAFT_ASSEMBLED,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article_id,
|
||||||
|
event_type="DRAFT_ASSEMBLED",
|
||||||
|
from_status=article.status,
|
||||||
|
to_status=ArticleWorkflowStatus.DRAFT_ASSEMBLED,
|
||||||
|
actor_user_id=None,
|
||||||
|
payload={
|
||||||
|
"draft_id": str(draft.id),
|
||||||
|
"version": draft.version,
|
||||||
|
"section_count": len(approved_plan.sections),
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return DraftAssembleResponse(draft=draft)
|
||||||
|
|
||||||
|
|
||||||
|
def list_drafts(repository: object, *, article_id: UUID) -> DraftListResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
return DraftListResponse(drafts=repository.article_drafts.list_for_article(article_id))
|
||||||
|
|
||||||
|
|
||||||
|
def get_draft(repository: object, *, article_id: UUID, draft_id: UUID) -> DraftResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
draft = repository.article_drafts.get(article_id=article_id, draft_id=draft_id)
|
||||||
|
return DraftResponse(draft=draft)
|
||||||
|
|
||||||
|
|
||||||
|
def update_draft(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
draft_id: UUID,
|
||||||
|
request: DraftUpdateRequest,
|
||||||
|
) -> DraftResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
base = repository.article_drafts.get(article_id=article_id, draft_id=draft_id)
|
||||||
|
if _request_is_empty(request):
|
||||||
|
raise ValueError("Draft update payload is empty")
|
||||||
|
|
||||||
|
next_version = repository.article_drafts.latest_version(article_id) + 1
|
||||||
|
now = _now()
|
||||||
|
title = request.title if request.title is not None else base.title
|
||||||
|
draft = repository.article_drafts.create_version(
|
||||||
|
article_id=article_id,
|
||||||
|
version=next_version,
|
||||||
|
title=title,
|
||||||
|
slug=_slugify(title),
|
||||||
|
meta_title=(
|
||||||
|
request.meta_title if request.meta_title is not None else base.meta_title
|
||||||
|
),
|
||||||
|
meta_description=(
|
||||||
|
request.meta_description
|
||||||
|
if request.meta_description is not None
|
||||||
|
else base.meta_description
|
||||||
|
),
|
||||||
|
body_object_key=f"articles/{article_id}/drafts/v{next_version}.md",
|
||||||
|
body_markdown=(
|
||||||
|
request.body_markdown
|
||||||
|
if request.body_markdown is not None
|
||||||
|
else base.body_markdown
|
||||||
|
),
|
||||||
|
faq_items=[
|
||||||
|
item.model_dump(mode="json")
|
||||||
|
for item in (
|
||||||
|
request.faq_items
|
||||||
|
if request.faq_items is not None
|
||||||
|
else base.faq_items
|
||||||
|
)
|
||||||
|
],
|
||||||
|
visual_placeholders=(
|
||||||
|
request.visual_placeholders
|
||||||
|
if request.visual_placeholders is not None
|
||||||
|
else base.visual_placeholders
|
||||||
|
),
|
||||||
|
evidence_references=(
|
||||||
|
request.evidence_references
|
||||||
|
if request.evidence_references is not None
|
||||||
|
else base.evidence_references
|
||||||
|
),
|
||||||
|
unsupported_claim_warnings=(
|
||||||
|
request.unsupported_claim_warnings
|
||||||
|
if request.unsupported_claim_warnings is not None
|
||||||
|
else base.unsupported_claim_warnings
|
||||||
|
),
|
||||||
|
based_on_draft_id=base.id,
|
||||||
|
status=base.status,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article_id,
|
||||||
|
event_type="DRAFT_VERSION_CREATED",
|
||||||
|
from_status=ArticleWorkflowStatus.DRAFT_ASSEMBLED,
|
||||||
|
to_status=ArticleWorkflowStatus.DRAFT_ASSEMBLED,
|
||||||
|
actor_user_id=None,
|
||||||
|
payload={
|
||||||
|
"draft_id": str(draft.id),
|
||||||
|
"version": draft.version,
|
||||||
|
"based_on_draft_id": str(base.id),
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return DraftResponse(draft=draft)
|
||||||
|
|
||||||
|
|
||||||
|
def _approved_plan(repository: object, *, article_id: UUID) -> object | None:
|
||||||
|
plans = repository.article_plans.list_for_article(article_id)
|
||||||
|
approved_plans = [
|
||||||
|
plan for plan in plans if plan.status == PlanReviewStatus.APPROVED
|
||||||
|
]
|
||||||
|
if not approved_plans:
|
||||||
|
return None
|
||||||
|
approved_plans.sort(key=lambda plan: plan.version)
|
||||||
|
return approved_plans[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _successful_jobs_by_section(
|
||||||
|
jobs: list[AgentJobSummary],
|
||||||
|
) -> dict[str, AgentJobSummary]:
|
||||||
|
sections: dict[str, AgentJobSummary] = {}
|
||||||
|
for job in jobs:
|
||||||
|
if job.job_type != AgentJobType.SECTION_SCAFFOLD:
|
||||||
|
continue
|
||||||
|
if job.status != AgentJobStatus.SUCCEEDED:
|
||||||
|
continue
|
||||||
|
section_id = _string_value(job.payload.get("section_id"))
|
||||||
|
if not section_id:
|
||||||
|
continue
|
||||||
|
existing = sections.get(section_id)
|
||||||
|
if existing is None or _job_sort_key(job) > _job_sort_key(existing):
|
||||||
|
sections[section_id] = job
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_job_warnings(jobs: list[AgentJobSummary]) -> list[str]:
|
||||||
|
warnings: list[str] = []
|
||||||
|
for job in jobs:
|
||||||
|
if job.job_type != AgentJobType.SECTION_SCAFFOLD:
|
||||||
|
continue
|
||||||
|
heading = _string_value(job.payload.get("heading")) or _string_value(
|
||||||
|
job.payload.get("artifact_label")
|
||||||
|
)
|
||||||
|
warnings.extend(
|
||||||
|
_unsupported_claim_warnings(
|
||||||
|
heading=heading or "section",
|
||||||
|
raw_claims=job.payload.get("unsupported_claims"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
def _job_sort_key(job: AgentJobSummary) -> tuple[int, str]:
|
||||||
|
queued_at = job.queued_at.isoformat() if job.queued_at else ""
|
||||||
|
return (job.attempt, queued_at)
|
||||||
|
|
||||||
|
|
||||||
|
def _section_markdown(heading: str, payload: dict[str, Any]) -> str:
|
||||||
|
section_markdown = _string_value(payload.get("draft_markdown")).strip()
|
||||||
|
if not section_markdown:
|
||||||
|
return f"## {heading}\n\nDraft content is missing for this section."
|
||||||
|
if section_markdown.lstrip().startswith("#"):
|
||||||
|
return section_markdown
|
||||||
|
return f"## {heading}\n\n{section_markdown}"
|
||||||
|
|
||||||
|
|
||||||
|
def _visual_placeholders_for_section(
|
||||||
|
*,
|
||||||
|
section_id: UUID,
|
||||||
|
heading: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
approved_assets: list[object],
|
||||||
|
) -> list[str]:
|
||||||
|
if approved_assets:
|
||||||
|
return [_approved_asset_markdown(asset) for asset in approved_assets]
|
||||||
|
|
||||||
|
suggested_visuals = payload.get("suggested_visuals")
|
||||||
|
placeholders: list[str] = []
|
||||||
|
if isinstance(suggested_visuals, list):
|
||||||
|
for index, visual in enumerate(suggested_visuals, start=1):
|
||||||
|
label = _visual_label(visual, heading=heading, fallback_index=index)
|
||||||
|
placeholders.append(
|
||||||
|
f""
|
||||||
|
)
|
||||||
|
if placeholders:
|
||||||
|
return placeholders
|
||||||
|
return [f""]
|
||||||
|
|
||||||
|
|
||||||
|
def _approved_assets_by_section(repository: object, *, article_id: UUID) -> dict[str, list[object]]:
|
||||||
|
assets = repository.assets.list_for_article(article_id)
|
||||||
|
by_section: dict[str, list[object]] = {}
|
||||||
|
for asset in assets:
|
||||||
|
if asset.status != AssetStatus.APPROVED:
|
||||||
|
continue
|
||||||
|
if not asset.section_id:
|
||||||
|
continue
|
||||||
|
if not asset.file_url:
|
||||||
|
continue
|
||||||
|
by_section.setdefault(str(asset.section_id), []).append(asset)
|
||||||
|
return by_section
|
||||||
|
|
||||||
|
|
||||||
|
def _approved_asset_markdown(asset: object) -> str:
|
||||||
|
alt = _string_value(getattr(asset, "alt_text", None)) or _string_value(
|
||||||
|
getattr(asset, "title", None)
|
||||||
|
)
|
||||||
|
src = _string_value(getattr(asset, "file_url", None))
|
||||||
|
if not src:
|
||||||
|
return ""
|
||||||
|
return f""
|
||||||
|
|
||||||
|
|
||||||
|
def _visual_label(value: Any, *, heading: str, fallback_index: int) -> str:
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key in ("title", "caption", "prompt", "label"):
|
||||||
|
key_value = value.get(key)
|
||||||
|
if isinstance(key_value, str) and key_value.strip():
|
||||||
|
return key_value.strip()
|
||||||
|
return f"{heading} visual {fallback_index}"
|
||||||
|
|
||||||
|
|
||||||
|
def _unsupported_claim_warnings(*, heading: str, raw_claims: Any) -> list[str]:
|
||||||
|
if not isinstance(raw_claims, list):
|
||||||
|
return []
|
||||||
|
warnings: list[str] = []
|
||||||
|
for claim in raw_claims:
|
||||||
|
if isinstance(claim, str) and claim.strip():
|
||||||
|
warnings.append(f"{heading}: {claim.strip()}")
|
||||||
|
continue
|
||||||
|
if isinstance(claim, dict):
|
||||||
|
claim_text = claim.get("claim_text")
|
||||||
|
if isinstance(claim_text, str) and claim_text.strip():
|
||||||
|
warnings.append(f"{heading}: {claim_text.strip()}")
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
def _build_faq_items(
|
||||||
|
*,
|
||||||
|
claims_to_prove: list[str],
|
||||||
|
keyword: str | None,
|
||||||
|
) -> list[DraftFaqItem]:
|
||||||
|
faq_items: list[DraftFaqItem] = []
|
||||||
|
for claim in claims_to_prove[:3]:
|
||||||
|
text = claim.strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
faq_items.append(
|
||||||
|
DraftFaqItem(
|
||||||
|
question=f"What evidence supports: {text}?",
|
||||||
|
answer="Evidence references are listed in the section and summary blocks.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if faq_items:
|
||||||
|
return faq_items
|
||||||
|
base_keyword = (keyword or "this topic").strip()
|
||||||
|
return [
|
||||||
|
DraftFaqItem(
|
||||||
|
question=f"What should readers know about {base_keyword}?",
|
||||||
|
answer="The draft sections provide context, implementation details, and references.",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _assemble_body_markdown(
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
meta_title: str,
|
||||||
|
meta_description: str,
|
||||||
|
section_blocks: list[str],
|
||||||
|
evidence_references: list[str],
|
||||||
|
visual_placeholders: list[str],
|
||||||
|
faq_items: list[DraftFaqItem],
|
||||||
|
unsupported_warnings: list[str],
|
||||||
|
) -> str:
|
||||||
|
lines = [
|
||||||
|
f"# {title}",
|
||||||
|
"",
|
||||||
|
f"> Meta title: {meta_title}",
|
||||||
|
f"> Meta description: {meta_description}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
lines.extend("\n\n".join(section_blocks).splitlines())
|
||||||
|
lines.extend(["", "## Evidence References", ""])
|
||||||
|
if evidence_references:
|
||||||
|
for evidence_id in evidence_references:
|
||||||
|
lines.append(f"- `{evidence_id}`")
|
||||||
|
else:
|
||||||
|
lines.append("- No evidence references recorded.")
|
||||||
|
|
||||||
|
lines.extend(["", "## Visual Placeholders", ""])
|
||||||
|
for placeholder in visual_placeholders:
|
||||||
|
lines.append(placeholder)
|
||||||
|
|
||||||
|
lines.extend(["", "## FAQ", ""])
|
||||||
|
for item in faq_items:
|
||||||
|
lines.append(f"### {item.question}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(item.answer)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if unsupported_warnings:
|
||||||
|
lines.extend(["## Unsupported Claim Warnings", ""])
|
||||||
|
for warning in unsupported_warnings:
|
||||||
|
lines.append(f"- {warning}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines).strip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_description(value: str) -> str:
|
||||||
|
normalized = " ".join(value.split())
|
||||||
|
if len(normalized) <= 155:
|
||||||
|
return normalized
|
||||||
|
return normalized[:152].rstrip() + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(value: str) -> str:
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
|
||||||
|
normalized = normalized.strip("-")
|
||||||
|
if normalized:
|
||||||
|
return normalized
|
||||||
|
return "draft"
|
||||||
|
|
||||||
|
|
||||||
|
def _string_list(value: Any) -> list[str]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
return [item for item in value if isinstance(item, str) and item]
|
||||||
|
|
||||||
|
|
||||||
|
def _string_value(value: Any) -> str:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_strings(values: list[str]) -> list[str]:
|
||||||
|
deduped: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for value in values:
|
||||||
|
if value in seen:
|
||||||
|
continue
|
||||||
|
seen.add(value)
|
||||||
|
deduped.append(value)
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
|
||||||
|
def _request_is_empty(request: DraftUpdateRequest) -> bool:
|
||||||
|
return all(
|
||||||
|
value is None
|
||||||
|
for value in (
|
||||||
|
request.title,
|
||||||
|
request.meta_title,
|
||||||
|
request.meta_description,
|
||||||
|
request.body_markdown,
|
||||||
|
request.faq_items,
|
||||||
|
request.visual_placeholders,
|
||||||
|
request.evidence_references,
|
||||||
|
request.unsupported_claim_warnings,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from src.application.agent_jobs import complete_agent_job
|
||||||
|
from src.application.demo_runtime import (
|
||||||
|
build_demo_section_completion_payload,
|
||||||
|
should_auto_run_section_jobs,
|
||||||
|
)
|
||||||
|
from src.domain.contracts import (
|
||||||
|
AgentJobListResponse,
|
||||||
|
AgentJobStatus,
|
||||||
|
AgentJobType,
|
||||||
|
ArticleCreateResponse,
|
||||||
|
ArticleWorkflowStatus,
|
||||||
|
ClaimRiskLevel,
|
||||||
|
ClaimSupportStatus,
|
||||||
|
EvidenceMatrixResponse,
|
||||||
|
EvidenceResponse,
|
||||||
|
EvidenceCreateRequest,
|
||||||
|
EvidenceUpdateRequest,
|
||||||
|
PlanReviewStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_evidence_matrix(repository: object, *, article_id: UUID) -> EvidenceMatrixResponse:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
manifests = repository.research_manifests.list_for_article(article_id)
|
||||||
|
if not manifests:
|
||||||
|
return EvidenceMatrixResponse(article=article)
|
||||||
|
|
||||||
|
if not repository.claims.list_for_article(article_id):
|
||||||
|
_build_matrix(repository, article=article, manifest=manifests[-1])
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
|
||||||
|
evidence = repository.evidence_items.list_for_article(article_id)
|
||||||
|
claims = repository.claims.list_for_article(article_id)
|
||||||
|
reasons = _insufficient_reasons(claims)
|
||||||
|
if reasons and article.status != ArticleWorkflowStatus.PLAN_REVISION_REQUIRED:
|
||||||
|
from_status = article.status
|
||||||
|
now = _now()
|
||||||
|
article = repository.articles.update_status(
|
||||||
|
article_id=article_id,
|
||||||
|
status=ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article_id,
|
||||||
|
event_type="INSUFFICIENT_EVIDENCE_FOUND",
|
||||||
|
from_status=from_status,
|
||||||
|
to_status=ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
|
||||||
|
actor_user_id=None,
|
||||||
|
payload={"reasons": reasons},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
if not reasons and article.status == ArticleWorkflowStatus.RESEARCH_RUNNING:
|
||||||
|
now = _now()
|
||||||
|
article = repository.articles.update_status(
|
||||||
|
article_id=article_id,
|
||||||
|
status=ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article_id,
|
||||||
|
event_type="EVIDENCE_MATRIX_READY",
|
||||||
|
from_status=ArticleWorkflowStatus.RESEARCH_RUNNING,
|
||||||
|
to_status=ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
|
||||||
|
actor_user_id=None,
|
||||||
|
payload={
|
||||||
|
"evidence_count": len(evidence),
|
||||||
|
"claim_count": len(claims),
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
return EvidenceMatrixResponse(
|
||||||
|
article=article,
|
||||||
|
evidence=evidence,
|
||||||
|
claims=claims,
|
||||||
|
insufficient_evidence_reasons=reasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_evidence(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
evidence_id: UUID,
|
||||||
|
request: EvidenceUpdateRequest,
|
||||||
|
) -> EvidenceResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
evidence = repository.evidence_items.update_review_status(
|
||||||
|
article_id=article_id,
|
||||||
|
evidence_id=evidence_id,
|
||||||
|
review_status=request.review_status or "PENDING",
|
||||||
|
)
|
||||||
|
return EvidenceResponse(evidence=evidence)
|
||||||
|
|
||||||
|
|
||||||
|
def add_evidence(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
request: EvidenceCreateRequest,
|
||||||
|
) -> EvidenceResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
now = _now()
|
||||||
|
evidence = repository.evidence_items.create(
|
||||||
|
article_id=article_id,
|
||||||
|
source_title=request.source_title,
|
||||||
|
source_url=request.source_url,
|
||||||
|
source_type=request.source_type,
|
||||||
|
source_quality_score=request.source_quality_score,
|
||||||
|
summary=request.summary,
|
||||||
|
supports_claims=[],
|
||||||
|
artifact_manifest_id=_resolve_manifest_id(repository, article_id=article_id),
|
||||||
|
retrieved_at=now,
|
||||||
|
review_status="PENDING",
|
||||||
|
)
|
||||||
|
if request.claim_text:
|
||||||
|
repository.claims.create(
|
||||||
|
article_id=article_id,
|
||||||
|
section_id=request.section_id,
|
||||||
|
claim_text=request.claim_text,
|
||||||
|
support_status=request.support_status,
|
||||||
|
risk_level=request.risk_level,
|
||||||
|
evidence_item_ids=[evidence.id],
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return EvidenceResponse(evidence=evidence)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_evidence(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
evidence_id: UUID,
|
||||||
|
) -> None:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
evidence = repository.evidence_items.get(evidence_id)
|
||||||
|
if evidence.article_id != article_id:
|
||||||
|
raise LookupError(f"Evidence not found: {evidence_id}")
|
||||||
|
|
||||||
|
for claim in repository.claims.list_for_article(article_id):
|
||||||
|
if evidence_id not in claim.evidence_item_ids:
|
||||||
|
continue
|
||||||
|
remaining_ids = [item_id for item_id in claim.evidence_item_ids if item_id != evidence_id]
|
||||||
|
repository.claims.update_evidence_links(
|
||||||
|
claim_id=claim.id,
|
||||||
|
evidence_item_ids=remaining_ids,
|
||||||
|
support_status=(
|
||||||
|
claim.support_status
|
||||||
|
if remaining_ids
|
||||||
|
else ClaimSupportStatus.UNSUPPORTED
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.evidence_items.delete(article_id=article_id, evidence_id=evidence_id)
|
||||||
|
|
||||||
|
|
||||||
|
def start_draft(repository: object, *, article_id: UUID) -> AgentJobListResponse:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
if article.status != ArticleWorkflowStatus.EVIDENCE_MATRIX_READY:
|
||||||
|
raise PermissionError("Evidence matrix must be ready before draft production")
|
||||||
|
|
||||||
|
matrix = get_evidence_matrix(repository, article_id=article_id)
|
||||||
|
if matrix.insufficient_evidence_reasons:
|
||||||
|
raise PermissionError("Acceptable evidence is required before draft production")
|
||||||
|
if matrix.article.status != ArticleWorkflowStatus.EVIDENCE_MATRIX_READY:
|
||||||
|
raise PermissionError("Evidence matrix must be ready before draft production")
|
||||||
|
|
||||||
|
approved_plan = _approved_plan(repository, article_id=article_id)
|
||||||
|
if approved_plan is None or not approved_plan.sections:
|
||||||
|
raise PermissionError("Approved plan with sections is required before draft production")
|
||||||
|
|
||||||
|
claim_evidence_by_section = _claim_evidence_ids_by_section(matrix.claims)
|
||||||
|
now = _now()
|
||||||
|
repository.articles.update_status(
|
||||||
|
article_id=article_id,
|
||||||
|
status=ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article_id,
|
||||||
|
event_type="PARALLEL_PRODUCTION_STARTED",
|
||||||
|
from_status=ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
|
||||||
|
to_status=ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING,
|
||||||
|
actor_user_id=None,
|
||||||
|
payload={"section_count": len(approved_plan.sections)},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
jobs = []
|
||||||
|
for section in approved_plan.sections:
|
||||||
|
used_evidence_ids = claim_evidence_by_section.get(str(section.id), [])
|
||||||
|
demo_fail_once = should_auto_run_section_jobs() and len(jobs) == 0
|
||||||
|
jobs.append(
|
||||||
|
repository.agent_jobs.create(
|
||||||
|
article_id=article_id,
|
||||||
|
parent_job_id=None,
|
||||||
|
attempt=1,
|
||||||
|
job_type=AgentJobType.SECTION_SCAFFOLD,
|
||||||
|
agent_profile="fake-section-scaffold",
|
||||||
|
status=AgentJobStatus.QUEUED,
|
||||||
|
input_files=[
|
||||||
|
{"path": "inputs/evidence-matrix.json", "content_hash": None},
|
||||||
|
{"path": "inputs/approved-plan.json", "content_hash": None},
|
||||||
|
{"path": f"inputs/sections/{section.id}.json", "content_hash": None},
|
||||||
|
],
|
||||||
|
payload={
|
||||||
|
"artifact_key": f"section:{section.id}",
|
||||||
|
"artifact_label": section.heading,
|
||||||
|
"artifact_type": "section_scaffold",
|
||||||
|
"section_id": str(section.id),
|
||||||
|
"heading": section.heading,
|
||||||
|
"used_evidence_ids": used_evidence_ids,
|
||||||
|
"unsupported_claims": [],
|
||||||
|
"suggested_visuals": [],
|
||||||
|
"draft_markdown": "",
|
||||||
|
"demo_fail_once": demo_fail_once,
|
||||||
|
},
|
||||||
|
queued_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if should_auto_run_section_jobs():
|
||||||
|
_auto_complete_demo_section_jobs(repository, jobs)
|
||||||
|
jobs = repository.agent_jobs.list_for_article(article_id)
|
||||||
|
|
||||||
|
return AgentJobListResponse(jobs=jobs)
|
||||||
|
|
||||||
|
|
||||||
|
def approve_final(repository: object, *, article_id: UUID) -> ArticleCreateResponse:
|
||||||
|
matrix = get_evidence_matrix(repository, article_id=article_id)
|
||||||
|
high_risk_unsupported = [
|
||||||
|
claim
|
||||||
|
for claim in matrix.claims
|
||||||
|
if claim.support_status == ClaimSupportStatus.UNSUPPORTED
|
||||||
|
and claim.risk_level == ClaimRiskLevel.HIGH
|
||||||
|
]
|
||||||
|
if high_risk_unsupported:
|
||||||
|
raise PermissionError("High-risk unsupported claims must be resolved")
|
||||||
|
return ArticleCreateResponse(article=matrix.article)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_matrix(repository: object, *, article: object, manifest: object) -> None:
|
||||||
|
now = _now()
|
||||||
|
insufficient = "insufficient" in (
|
||||||
|
f"{article.brief_description} {article.primary_keyword or ''}".lower()
|
||||||
|
)
|
||||||
|
plan = repository.article_plans.list_for_article(article.id)[-1]
|
||||||
|
first_section_id = plan.sections[0].id if plan.sections else None
|
||||||
|
if insufficient:
|
||||||
|
repository.claims.create(
|
||||||
|
article_id=article.id,
|
||||||
|
section_id=first_section_id,
|
||||||
|
claim_text="High-risk factual claim without acceptable source support.",
|
||||||
|
support_status=ClaimSupportStatus.UNSUPPORTED,
|
||||||
|
risk_level=ClaimRiskLevel.HIGH,
|
||||||
|
evidence_item_ids=[],
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
for artifact in manifest.artifacts:
|
||||||
|
metadata = artifact.metadata
|
||||||
|
evidence = repository.evidence_items.create(
|
||||||
|
article_id=article.id,
|
||||||
|
source_title=str(metadata.get("source_title", "Research source")),
|
||||||
|
source_url=artifact.source_url,
|
||||||
|
source_type=str(metadata.get("source_type", artifact.artifact_type)),
|
||||||
|
source_quality_score=0.86,
|
||||||
|
summary=str(metadata.get("summary", "Research source summary")),
|
||||||
|
supports_claims=[],
|
||||||
|
artifact_manifest_id=manifest.id,
|
||||||
|
retrieved_at=now,
|
||||||
|
)
|
||||||
|
repository.claims.create(
|
||||||
|
article_id=article.id,
|
||||||
|
section_id=first_section_id,
|
||||||
|
claim_text=str(metadata.get("summary", "Supported factual claim")),
|
||||||
|
support_status=ClaimSupportStatus.SUPPORTED,
|
||||||
|
risk_level=ClaimRiskLevel.LOW,
|
||||||
|
evidence_item_ids=[evidence.id],
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_manifest_id(repository: object, *, article_id: UUID) -> UUID:
|
||||||
|
manifests = repository.research_manifests.list_for_article(article_id)
|
||||||
|
if manifests:
|
||||||
|
return manifests[-1].id
|
||||||
|
raise ValueError("Research manifest is required before adding manual evidence")
|
||||||
|
|
||||||
|
|
||||||
|
def _approved_plan(repository: object, article_id: UUID) -> object | None:
|
||||||
|
for plan in reversed(repository.article_plans.list_for_article(article_id)):
|
||||||
|
if plan.status == PlanReviewStatus.APPROVED:
|
||||||
|
return plan
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_evidence_ids_by_section(claims: list[object]) -> dict[str, list[str]]:
|
||||||
|
mapping: dict[str, list[str]] = {}
|
||||||
|
for claim in claims:
|
||||||
|
if claim.section_id is None:
|
||||||
|
continue
|
||||||
|
section_id = str(claim.section_id)
|
||||||
|
bucket = mapping.setdefault(section_id, [])
|
||||||
|
for evidence_id in claim.evidence_item_ids:
|
||||||
|
encoded = str(evidence_id)
|
||||||
|
if encoded not in bucket:
|
||||||
|
bucket.append(encoded)
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def _insufficient_reasons(claims: list[object]) -> list[str]:
|
||||||
|
reasons: list[str] = []
|
||||||
|
for claim in claims:
|
||||||
|
if claim.support_status == ClaimSupportStatus.UNSUPPORTED:
|
||||||
|
reasons.append(f"Unsupported claim: {claim.claim_text}")
|
||||||
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_complete_demo_section_jobs(
|
||||||
|
repository: object,
|
||||||
|
jobs: list[object],
|
||||||
|
) -> None:
|
||||||
|
for index, job in enumerate(jobs):
|
||||||
|
failed = index == 0
|
||||||
|
output = build_demo_section_completion_payload(
|
||||||
|
payload=job.payload,
|
||||||
|
failed=failed,
|
||||||
|
)
|
||||||
|
complete_agent_job(
|
||||||
|
repository,
|
||||||
|
job_id=job.id,
|
||||||
|
workspace_path=f"/tmp/demo/{job.id}",
|
||||||
|
stdout=(
|
||||||
|
"deterministic demo failure; retry to continue\n"
|
||||||
|
if failed
|
||||||
|
else "deterministic demo section scaffold succeeded\n"
|
||||||
|
),
|
||||||
|
stderr="" if not failed else "exit code 1 simulated\n",
|
||||||
|
exit_code=1 if failed else 0,
|
||||||
|
duration_ms=12,
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from src.domain.contracts import (
|
||||||
|
AgentJobType,
|
||||||
|
AssetStatus,
|
||||||
|
AssetType,
|
||||||
|
ArticleWorkflowStatus,
|
||||||
|
ClaimRiskLevel,
|
||||||
|
ClaimSupportStatus,
|
||||||
|
ContentReviewKind,
|
||||||
|
FinalApprovalChecklistItemSummary,
|
||||||
|
FinalApprovalChecklistResponse,
|
||||||
|
FinalApprovalRequest,
|
||||||
|
FinalApprovalResponse,
|
||||||
|
FinalRevisionRequest,
|
||||||
|
FinalRevisionRequestResponse,
|
||||||
|
PlanReviewStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_final_approval_checklist(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
request: FinalApprovalRequest | None = None,
|
||||||
|
) -> FinalApprovalChecklistResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
latest_draft = repository.article_drafts.latest_for_article(article_id)
|
||||||
|
approved_plan = _approved_plan(repository, article_id=article_id)
|
||||||
|
evidence = repository.evidence_items.list_for_article(article_id)
|
||||||
|
assets = repository.assets.list_for_article(article_id)
|
||||||
|
unresolved = repository.content_reviews.list_unresolved_for_article(article_id=article_id)
|
||||||
|
high_risk_unsupported = _high_risk_unsupported_claims(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
draft=latest_draft,
|
||||||
|
)
|
||||||
|
settings = request.publishing_settings if request is not None else None
|
||||||
|
requested_draft_version = request.draft_version if request is not None else None
|
||||||
|
|
||||||
|
checklist_items: list[FinalApprovalChecklistItemSummary] = []
|
||||||
|
blockers: list[str] = []
|
||||||
|
completed_items: list[str] = []
|
||||||
|
|
||||||
|
def add_item(key: str, title: str, completed: bool, blocker: str | None = None) -> None:
|
||||||
|
checklist_items.append(
|
||||||
|
FinalApprovalChecklistItemSummary(
|
||||||
|
key=key,
|
||||||
|
title=title,
|
||||||
|
completed=completed,
|
||||||
|
blocker=blocker,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if completed:
|
||||||
|
completed_items.append(title)
|
||||||
|
elif blocker:
|
||||||
|
blockers.append(blocker)
|
||||||
|
|
||||||
|
has_draft = latest_draft is not None
|
||||||
|
add_item(
|
||||||
|
key="exact_draft_version",
|
||||||
|
title="Exact draft version selected",
|
||||||
|
completed=has_draft and requested_draft_version == latest_draft.version,
|
||||||
|
blocker=(
|
||||||
|
None
|
||||||
|
if has_draft and requested_draft_version == latest_draft.version
|
||||||
|
else (
|
||||||
|
"Draft version is required for final approval."
|
||||||
|
if not has_draft
|
||||||
|
else f"Final approval requires latest draft version {latest_draft.version}."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
add_item(
|
||||||
|
key="plan_followed",
|
||||||
|
title="Plan followed",
|
||||||
|
completed=has_draft and _draft_contains_approved_plan(approved_plan, latest_draft.body_markdown),
|
||||||
|
blocker="Approved plan sections are not fully reflected in the assembled draft.",
|
||||||
|
)
|
||||||
|
add_item(
|
||||||
|
key="evidence_reviewed",
|
||||||
|
title="Evidence reviewed",
|
||||||
|
completed=bool(evidence) and all(item.review_status != "PENDING" for item in evidence),
|
||||||
|
blocker="Evidence review is incomplete (there are pending evidence items).",
|
||||||
|
)
|
||||||
|
add_item(
|
||||||
|
key="no_high_risk_unsupported_claims",
|
||||||
|
title="No high-risk unsupported claims remain",
|
||||||
|
completed=not high_risk_unsupported,
|
||||||
|
blocker=(
|
||||||
|
None
|
||||||
|
if not high_risk_unsupported
|
||||||
|
else (
|
||||||
|
"High-risk unsupported claims must be resolved: "
|
||||||
|
+ "; ".join(high_risk_unsupported[:3])
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
add_item(
|
||||||
|
key="seo_metadata_approved",
|
||||||
|
title="SEO metadata approved",
|
||||||
|
completed=has_draft and _has_seo_metadata(latest_draft) and not _has_unresolved_meta_issues(unresolved),
|
||||||
|
blocker="SEO metadata is incomplete or has unresolved review issues.",
|
||||||
|
)
|
||||||
|
|
||||||
|
required_assets = [asset for asset in assets if asset.status != AssetStatus.REJECTED]
|
||||||
|
unapproved_required_assets = [
|
||||||
|
asset
|
||||||
|
for asset in required_assets
|
||||||
|
if asset.status != AssetStatus.APPROVED or not asset.file_url
|
||||||
|
]
|
||||||
|
add_item(
|
||||||
|
key="images_assets_approved",
|
||||||
|
title="Images/assets approved",
|
||||||
|
completed=bool(required_assets) and not unapproved_required_assets,
|
||||||
|
blocker=(
|
||||||
|
"Required assets are missing."
|
||||||
|
if not required_assets
|
||||||
|
else f"{len(unapproved_required_assets)} required assets are not approved."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
required_table_diagrams = [
|
||||||
|
asset
|
||||||
|
for asset in required_assets
|
||||||
|
if asset.asset_type in {
|
||||||
|
AssetType.DIAGRAM,
|
||||||
|
AssetType.TABLE,
|
||||||
|
AssetType.FLOWCHART,
|
||||||
|
AssetType.COMPARISON_MATRIX,
|
||||||
|
AssetType.ARCHITECTURE_DIAGRAM,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
unapproved_table_diagrams = [
|
||||||
|
asset
|
||||||
|
for asset in required_table_diagrams
|
||||||
|
if asset.status != AssetStatus.APPROVED or not asset.file_url
|
||||||
|
]
|
||||||
|
add_item(
|
||||||
|
key="tables_and_diagrams_approved",
|
||||||
|
title="Tables and diagrams approved",
|
||||||
|
completed=bool(required_table_diagrams) and not unapproved_table_diagrams,
|
||||||
|
blocker=(
|
||||||
|
"Required table/diagram assets are missing."
|
||||||
|
if not required_table_diagrams
|
||||||
|
else f"{len(unapproved_table_diagrams)} table/diagram assets are not approved."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
has_internal_links = has_draft and _has_internal_links(latest_draft.body_markdown)
|
||||||
|
add_item(
|
||||||
|
key="internal_links_approved",
|
||||||
|
title="Internal links approved",
|
||||||
|
completed=bool(has_internal_links) and not _has_unresolved_internal_link_issue(unresolved),
|
||||||
|
blocker="Draft must include approved internal links.",
|
||||||
|
)
|
||||||
|
|
||||||
|
frontmatter = settings.frontmatter if settings is not None else None
|
||||||
|
add_item(
|
||||||
|
key="frontmatter_fields_selected",
|
||||||
|
title="Frontmatter fields selected",
|
||||||
|
completed=isinstance(frontmatter, dict) and bool(frontmatter),
|
||||||
|
blocker="Required publishing settings are missing: frontmatter fields.",
|
||||||
|
)
|
||||||
|
content_path = settings.content_path if settings is not None else None
|
||||||
|
add_item(
|
||||||
|
key="content_path_selected",
|
||||||
|
title="Content path selected",
|
||||||
|
completed=bool(content_path and content_path.strip()),
|
||||||
|
blocker="Required publishing settings are missing: content path.",
|
||||||
|
)
|
||||||
|
author = settings.author if settings is not None else None
|
||||||
|
add_item(
|
||||||
|
key="author_selected",
|
||||||
|
title="Author selected",
|
||||||
|
completed=bool(author and author.strip()),
|
||||||
|
blocker="Required publishing settings are missing: author.",
|
||||||
|
)
|
||||||
|
publishing_mode = settings.publishing_mode if settings is not None else None
|
||||||
|
add_item(
|
||||||
|
key="publishing_mode_selected",
|
||||||
|
title="Publishing mode selected",
|
||||||
|
completed=bool(publishing_mode and publishing_mode.strip()),
|
||||||
|
blocker="Required publishing settings are missing: publishing mode.",
|
||||||
|
)
|
||||||
|
|
||||||
|
can_approve = len(blockers) == 0
|
||||||
|
return FinalApprovalChecklistResponse(
|
||||||
|
article_id=article_id,
|
||||||
|
draft_version=latest_draft.version if latest_draft is not None else None,
|
||||||
|
requested_draft_version=requested_draft_version,
|
||||||
|
can_approve=can_approve,
|
||||||
|
completed_items=completed_items,
|
||||||
|
blockers=blockers,
|
||||||
|
items=checklist_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def approve_final_article(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
request: FinalApprovalRequest,
|
||||||
|
actor_user_id: UUID,
|
||||||
|
) -> FinalApprovalResponse:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
checklist = get_final_approval_checklist(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
if not checklist.can_approve:
|
||||||
|
raise PermissionError("; ".join(checklist.blockers))
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
updated = repository.articles.update_status(
|
||||||
|
article_id=article_id,
|
||||||
|
status=ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article_id,
|
||||||
|
event_type="FINAL_APPROVAL_GRANTED",
|
||||||
|
from_status=article.status,
|
||||||
|
to_status=ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
payload={
|
||||||
|
"draft_version": checklist.draft_version,
|
||||||
|
"publishing_settings": (
|
||||||
|
request.publishing_settings.model_dump(mode="json")
|
||||||
|
if request.publishing_settings is not None
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
"completed_items": checklist.completed_items,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return FinalApprovalResponse(article=updated, checklist=checklist)
|
||||||
|
|
||||||
|
|
||||||
|
def request_final_revision(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
request: FinalRevisionRequest,
|
||||||
|
actor_user_id: UUID,
|
||||||
|
) -> FinalRevisionRequestResponse:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
checklist_request = FinalApprovalRequest(
|
||||||
|
draft_version=request.draft_version,
|
||||||
|
publishing_settings=None,
|
||||||
|
)
|
||||||
|
checklist = get_final_approval_checklist(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
request=checklist_request,
|
||||||
|
)
|
||||||
|
now = _now()
|
||||||
|
updated = repository.articles.update_status(
|
||||||
|
article_id=article_id,
|
||||||
|
status=ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article_id,
|
||||||
|
event_type="FINAL_REVISION_REQUESTED",
|
||||||
|
from_status=article.status,
|
||||||
|
to_status=ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
payload={
|
||||||
|
"reason": (request.reason or "").strip(),
|
||||||
|
"draft_version": checklist.draft_version,
|
||||||
|
"blockers": checklist.blockers,
|
||||||
|
"completed_items": checklist.completed_items,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return FinalRevisionRequestResponse(article=updated, checklist=checklist)
|
||||||
|
|
||||||
|
|
||||||
|
def _approved_plan(repository: object, *, article_id: UUID) -> object | None:
|
||||||
|
plans = repository.article_plans.list_for_article(article_id)
|
||||||
|
approved_plans = [plan for plan in plans if plan.status == PlanReviewStatus.APPROVED]
|
||||||
|
if not approved_plans:
|
||||||
|
return None
|
||||||
|
approved_plans.sort(key=lambda plan: plan.version)
|
||||||
|
return approved_plans[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _draft_contains_approved_plan(approved_plan: object | None, body_markdown: str) -> bool:
|
||||||
|
if approved_plan is None or not approved_plan.sections:
|
||||||
|
return False
|
||||||
|
for section in approved_plan.sections:
|
||||||
|
if f"## {section.heading}" not in body_markdown:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _has_seo_metadata(draft: object) -> bool:
|
||||||
|
return bool(draft.meta_title and draft.meta_title.strip()) and bool(
|
||||||
|
draft.meta_description and draft.meta_description.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_unresolved_meta_issues(unresolved: list[object]) -> bool:
|
||||||
|
return any(
|
||||||
|
item.review_kind == ContentReviewKind.SEO
|
||||||
|
and item.location in {"meta_title", "meta_description", "title"}
|
||||||
|
for item in unresolved
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_internal_links(markdown: str) -> bool:
|
||||||
|
return re.search(r"\]\(/[^)]+\)", markdown) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_unresolved_internal_link_issue(unresolved: list[object]) -> bool:
|
||||||
|
for item in unresolved:
|
||||||
|
if item.suggestion_key == "internal-link":
|
||||||
|
return True
|
||||||
|
if "internal link" in item.message.lower():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _high_risk_unsupported_claims(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
draft: object | None,
|
||||||
|
) -> list[str]:
|
||||||
|
claims: list[str] = []
|
||||||
|
for claim in repository.claims.list_for_article(article_id):
|
||||||
|
if (
|
||||||
|
claim.support_status == ClaimSupportStatus.UNSUPPORTED
|
||||||
|
and claim.risk_level == ClaimRiskLevel.HIGH
|
||||||
|
):
|
||||||
|
claims.append(claim.claim_text)
|
||||||
|
|
||||||
|
for job in repository.agent_jobs.list_for_article(article_id):
|
||||||
|
if job.job_type != AgentJobType.SECTION_SCAFFOLD:
|
||||||
|
continue
|
||||||
|
raw_claims = job.payload.get("unsupported_claims")
|
||||||
|
if not isinstance(raw_claims, list):
|
||||||
|
continue
|
||||||
|
for raw in raw_claims:
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
risk_level = str(raw.get("risk_level", "")).lower()
|
||||||
|
claim_text = str(raw.get("claim_text", "")).strip()
|
||||||
|
if risk_level == "high" and claim_text:
|
||||||
|
claims.append(claim_text)
|
||||||
|
|
||||||
|
if draft is not None:
|
||||||
|
for warning in draft.unsupported_claim_warnings:
|
||||||
|
if "high-risk" in warning.lower():
|
||||||
|
claims.append(warning)
|
||||||
|
|
||||||
|
deduped: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for claim in claims:
|
||||||
|
normalized = claim.strip()
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
if normalized in seen:
|
||||||
|
continue
|
||||||
|
seen.add(normalized)
|
||||||
|
deduped.append(normalized)
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.domain.contracts import (
|
||||||
|
AgentJobStatus,
|
||||||
|
AgentJobSummary,
|
||||||
|
AgentJobType,
|
||||||
|
PublishingStatus,
|
||||||
|
Role,
|
||||||
|
WorkflowEventSummary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_RETRYABLE_JOB_TYPES = {
|
||||||
|
AgentJobType.PLAN_GENERATION,
|
||||||
|
AgentJobType.RESEARCH,
|
||||||
|
AgentJobType.SECTION_SCAFFOLD,
|
||||||
|
AgentJobType.SEO_REVIEW,
|
||||||
|
AgentJobType.LANGUAGE_REVIEW,
|
||||||
|
AgentJobType.PUBLISH_COMMIT,
|
||||||
|
AgentJobType.TEST_CODEX,
|
||||||
|
}
|
||||||
|
|
||||||
|
_SENSITIVE_KEY_TOKENS = (
|
||||||
|
"token",
|
||||||
|
"password",
|
||||||
|
"secret",
|
||||||
|
"authorization",
|
||||||
|
"cookie",
|
||||||
|
"api_key",
|
||||||
|
"apikey",
|
||||||
|
"session",
|
||||||
|
)
|
||||||
|
|
||||||
|
_SENSITIVE_VALUE_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||||
|
(re.compile(r"(?i)(authorization\s*:\s*bearer\s+)[^\s]+"), r"\1[REDACTED]"),
|
||||||
|
(re.compile(r"(?i)(token\s*[=:]\s*)[^\s,;]+"), r"\1[REDACTED]"),
|
||||||
|
(re.compile(r"(?i)(password\s*[=:]\s*)[^\s,;]+"), r"\1[REDACTED]"),
|
||||||
|
(re.compile(r"(?i)(secret\s*[=:]\s*)[^\s,;]+"), r"\1[REDACTED]"),
|
||||||
|
(re.compile(r"(?i)(api[_-]?key\s*[=:]\s*)[^\s,;]+"), r"\1[REDACTED]"),
|
||||||
|
(re.compile(r"\b(sk-[a-zA-Z0-9_-]{8,})\b"), "[REDACTED]"),
|
||||||
|
(re.compile(r"\b(gh[pousr]_[a-zA-Z0-9]{8,})\b"), "[REDACTED]"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def project_job_for_view(
|
||||||
|
repository: object,
|
||||||
|
job: AgentJobSummary,
|
||||||
|
*,
|
||||||
|
viewer_role: Role,
|
||||||
|
) -> AgentJobSummary:
|
||||||
|
retry_eligible, retry_block_reason = evaluate_retry_policy(repository, job)
|
||||||
|
payload = redact_json_value(job.payload)
|
||||||
|
error_message = _nullable_redacted(job.error_message)
|
||||||
|
stdout = redact_text(job.stdout)
|
||||||
|
stderr = redact_text(job.stderr)
|
||||||
|
safe_failure_summary = build_safe_failure_summary(job)
|
||||||
|
|
||||||
|
if viewer_role != Role.ADMIN:
|
||||||
|
stdout = ""
|
||||||
|
stderr = ""
|
||||||
|
|
||||||
|
return job.model_copy(
|
||||||
|
update={
|
||||||
|
"payload": payload,
|
||||||
|
"error_message": error_message,
|
||||||
|
"stdout": stdout,
|
||||||
|
"stderr": stderr,
|
||||||
|
"retry_eligible": retry_eligible,
|
||||||
|
"retry_block_reason": retry_block_reason,
|
||||||
|
"cancel_eligible": is_cancel_eligible(job),
|
||||||
|
"safe_failure_summary": safe_failure_summary,
|
||||||
|
},
|
||||||
|
deep=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_retry_policy(
|
||||||
|
repository: object,
|
||||||
|
job: AgentJobSummary,
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
|
if job.status != AgentJobStatus.FAILED:
|
||||||
|
return False, "Retry is available only for failed jobs."
|
||||||
|
if job.job_type not in _RETRYABLE_JOB_TYPES:
|
||||||
|
return False, f"Retry is not supported for job type {job.job_type.value}."
|
||||||
|
if job.job_type == AgentJobType.PUBLISH_COMMIT:
|
||||||
|
if job.article_id is None:
|
||||||
|
return False, "Publish commit retry requires article context."
|
||||||
|
existing_publish_commit = repository.publish_commits.latest_for_article_with_statuses(
|
||||||
|
job.article_id,
|
||||||
|
statuses={PublishingStatus.PUBLISH_COMMIT_CREATED},
|
||||||
|
)
|
||||||
|
if existing_publish_commit is not None:
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
"Publish commit already exists for this article. Retry is blocked.",
|
||||||
|
)
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
def is_cancel_eligible(job: AgentJobSummary) -> bool:
|
||||||
|
return job.status in {AgentJobStatus.QUEUED, AgentJobStatus.RUNNING}
|
||||||
|
|
||||||
|
|
||||||
|
def build_safe_failure_summary(job: AgentJobSummary) -> str | None:
|
||||||
|
if job.status != AgentJobStatus.FAILED:
|
||||||
|
return None
|
||||||
|
category = job.error_category.value if job.error_category is not None else "UNKNOWN"
|
||||||
|
step = (
|
||||||
|
_string_value(job.payload.get("last_successful_step"))
|
||||||
|
or _string_value(job.payload.get("artifact_label"))
|
||||||
|
or _string_value(job.payload.get("heading"))
|
||||||
|
)
|
||||||
|
message = redact_text(job.error_message or "").strip()
|
||||||
|
parts = [f"{job.job_type.value} failed ({category})."]
|
||||||
|
if step:
|
||||||
|
parts.append(f"Last successful step: {step}.")
|
||||||
|
if message:
|
||||||
|
parts.append(f"Summary: {message}.")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def build_observability_timeline(
|
||||||
|
workflow_events: list[WorkflowEventSummary],
|
||||||
|
agent_jobs: list[AgentJobSummary],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
timeline: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for event in workflow_events:
|
||||||
|
source = "USER" if event.actor_user_id is not None else "SYSTEM"
|
||||||
|
timeline.append(
|
||||||
|
{
|
||||||
|
"id": event.id,
|
||||||
|
"article_id": event.article_id,
|
||||||
|
"entry_type": "WORKFLOW_EVENT",
|
||||||
|
"source": source,
|
||||||
|
"event_type": event.event_type,
|
||||||
|
"from_status": event.from_status,
|
||||||
|
"to_status": event.to_status,
|
||||||
|
"actor_user_id": event.actor_user_id,
|
||||||
|
"job_id": None,
|
||||||
|
"job_type": None,
|
||||||
|
"job_status": None,
|
||||||
|
"retry_eligible": False,
|
||||||
|
"cancel_eligible": False,
|
||||||
|
"safe_failure_summary": None,
|
||||||
|
"payload": redact_json_value(event.payload),
|
||||||
|
"created_at": event.created_at,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for job in agent_jobs:
|
||||||
|
created_at = job.finished_at or job.started_at or job.queued_at
|
||||||
|
timeline.append(
|
||||||
|
{
|
||||||
|
"id": job.id,
|
||||||
|
"article_id": job.article_id,
|
||||||
|
"entry_type": "AGENT_JOB",
|
||||||
|
"source": "AGENT",
|
||||||
|
"event_type": f"{job.job_type.value}_{job.status.value}",
|
||||||
|
"from_status": None,
|
||||||
|
"to_status": None,
|
||||||
|
"actor_user_id": None,
|
||||||
|
"job_id": job.id,
|
||||||
|
"job_type": job.job_type,
|
||||||
|
"job_status": job.status,
|
||||||
|
"retry_eligible": bool(job.retry_eligible),
|
||||||
|
"cancel_eligible": bool(job.cancel_eligible),
|
||||||
|
"safe_failure_summary": job.safe_failure_summary,
|
||||||
|
"payload": {
|
||||||
|
"attempt": job.attempt,
|
||||||
|
"error_category": (
|
||||||
|
job.error_category.value if job.error_category is not None else None
|
||||||
|
),
|
||||||
|
"error_message": job.error_message,
|
||||||
|
},
|
||||||
|
"created_at": created_at,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
timeline.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
_timeline_dt(item.get("created_at")),
|
||||||
|
str(item.get("id")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return timeline
|
||||||
|
|
||||||
|
|
||||||
|
def redact_json_value(value: Any) -> Any:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
redacted: dict[str, Any] = {}
|
||||||
|
for key, nested in value.items():
|
||||||
|
if _contains_sensitive_token(key):
|
||||||
|
redacted[key] = "[REDACTED]"
|
||||||
|
else:
|
||||||
|
redacted[key] = redact_json_value(nested)
|
||||||
|
return redacted
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [redact_json_value(item) for item in value]
|
||||||
|
if isinstance(value, str):
|
||||||
|
return redact_text(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def redact_text(value: str) -> str:
|
||||||
|
redacted = value
|
||||||
|
for pattern, replacement in _SENSITIVE_VALUE_PATTERNS:
|
||||||
|
redacted = pattern.sub(replacement, redacted)
|
||||||
|
return redacted
|
||||||
|
|
||||||
|
|
||||||
|
def _timeline_dt(value: Any) -> datetime:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
return datetime.min
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_sensitive_token(value: str) -> bool:
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
return any(token in normalized for token in _SENSITIVE_KEY_TOKENS)
|
||||||
|
|
||||||
|
|
||||||
|
def _string_value(value: Any) -> str:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _nullable_redacted(value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
redacted = redact_text(value).strip()
|
||||||
|
if not redacted:
|
||||||
|
return None
|
||||||
|
return redacted
|
||||||
@@ -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,776 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from src.infrastructure.object_storage import open_object_storage_client
|
||||||
|
from src.domain.contracts import (
|
||||||
|
ArticleSummary,
|
||||||
|
ArticleWorkflowStatus,
|
||||||
|
PublishCommitCreateResponse,
|
||||||
|
PublishCommitListResponse,
|
||||||
|
PublishCommitSummary,
|
||||||
|
PublishingDryRunResponse,
|
||||||
|
PublishingStatus,
|
||||||
|
PublishingStatusResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_VALIDATION_LABEL = "Best-effort content-shape validation only."
|
||||||
|
_FINAL_APPROVAL_EVENT = "FINAL_APPROVAL_GRANTED"
|
||||||
|
_RUNTIME_DIR = ".pipeline-runtime"
|
||||||
|
_PUBLISHING_YAML_PATH = f"{_RUNTIME_DIR}/publishing.yaml"
|
||||||
|
_TRANSFORM_SCRIPT_PATH = f"{_RUNTIME_DIR}/transform.mjs"
|
||||||
|
_TRANSFORM_RUNNER_PATH = f"{_RUNTIME_DIR}/run-transform.mjs"
|
||||||
|
_TRANSFORM_INPUT_PATH = f"{_RUNTIME_DIR}/transform-input.json"
|
||||||
|
_TRANSFORM_OUTPUT_PATH = f"{_RUNTIME_DIR}/transform-output.json"
|
||||||
|
|
||||||
|
|
||||||
|
def run_publishing_dry_run(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
actor_user_id: UUID,
|
||||||
|
) -> PublishingDryRunResponse:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
final_approval_event = _require_final_approval(repository, article_id=article_id)
|
||||||
|
if article.status not in {
|
||||||
|
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
|
||||||
|
ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
|
||||||
|
}:
|
||||||
|
raise PermissionError("Publishing dry run is allowed only after final approval.")
|
||||||
|
|
||||||
|
bundle_context = _load_bundle_context(
|
||||||
|
repository,
|
||||||
|
article=article,
|
||||||
|
final_approval_event=final_approval_event,
|
||||||
|
)
|
||||||
|
validation_errors = _validate_content_shape(bundle_context["markdown_body"])
|
||||||
|
content_shape_valid = len(validation_errors) == 0
|
||||||
|
now = _now()
|
||||||
|
status = (
|
||||||
|
PublishingStatus.PUBLISH_COMMIT_READY
|
||||||
|
if content_shape_valid
|
||||||
|
else PublishingStatus.PUBLISH_DRY_RUN_FAILED
|
||||||
|
)
|
||||||
|
manifest = _build_manifest(
|
||||||
|
bundle_context=bundle_context,
|
||||||
|
status=status,
|
||||||
|
validation_errors=validation_errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
publish_commit = repository.publish_commits.create(
|
||||||
|
article_id=article.id,
|
||||||
|
target_site_id=article.target_site_id,
|
||||||
|
repository_url=bundle_context["repository_url"],
|
||||||
|
branch=bundle_context["branch"],
|
||||||
|
commit_sha=None,
|
||||||
|
content_bundle_manifest=manifest,
|
||||||
|
status=status,
|
||||||
|
deployment_status=None,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
updated = _update_article_for_dry_run_result(
|
||||||
|
repository,
|
||||||
|
article=article,
|
||||||
|
content_shape_valid=content_shape_valid,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
validation_errors=validation_errors,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return PublishingDryRunResponse(
|
||||||
|
article=updated,
|
||||||
|
publish_commit=publish_commit,
|
||||||
|
content_shape_valid=content_shape_valid,
|
||||||
|
validation_label=_VALIDATION_LABEL,
|
||||||
|
errors=validation_errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_publish_commit(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
actor_user_id: UUID,
|
||||||
|
) -> PublishCommitCreateResponse:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
_require_final_approval(repository, article_id=article_id)
|
||||||
|
latest_dry_run = repository.publish_commits.latest_for_article_with_statuses(
|
||||||
|
article_id,
|
||||||
|
statuses={PublishingStatus.PUBLISH_COMMIT_READY},
|
||||||
|
)
|
||||||
|
if latest_dry_run is None:
|
||||||
|
raise PermissionError("Publish commit requires successful dry run.")
|
||||||
|
if article.status != ArticleWorkflowStatus.PUBLISH_COMMIT_READY:
|
||||||
|
raise PermissionError("Publish commit can run only from PUBLISH_COMMIT_READY status.")
|
||||||
|
|
||||||
|
manifest = dict(latest_dry_run.content_bundle_manifest or {})
|
||||||
|
git_info = manifest.get("git") if isinstance(manifest.get("git"), dict) else {}
|
||||||
|
expected_base_head_sha = git_info.get("base_head_sha")
|
||||||
|
repository_url = latest_dry_run.repository_url
|
||||||
|
branch = latest_dry_run.branch
|
||||||
|
current_head_sha = _resolve_remote_branch_head_sha(repository_url, branch)
|
||||||
|
if expected_base_head_sha and current_head_sha != expected_base_head_sha:
|
||||||
|
_record_publish_failure(
|
||||||
|
repository,
|
||||||
|
article=article,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
repository_url=repository_url,
|
||||||
|
branch=branch,
|
||||||
|
previous_manifest=manifest,
|
||||||
|
detail=(
|
||||||
|
"Non-fast-forward detected: remote branch advanced since dry run; "
|
||||||
|
"re-run dry run before creating commit."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
raise PermissionError(
|
||||||
|
"Non-fast-forward detected: remote branch advanced since dry run."
|
||||||
|
)
|
||||||
|
|
||||||
|
final_approval_event = _require_final_approval(repository, article_id=article_id)
|
||||||
|
bundle_context = _load_bundle_context(
|
||||||
|
repository,
|
||||||
|
article=article,
|
||||||
|
final_approval_event=final_approval_event,
|
||||||
|
)
|
||||||
|
commit_sha = _create_and_push_commit(bundle_context)
|
||||||
|
now = _now()
|
||||||
|
publish_commit = repository.publish_commits.create(
|
||||||
|
article_id=article.id,
|
||||||
|
target_site_id=article.target_site_id,
|
||||||
|
repository_url=repository_url,
|
||||||
|
branch=branch,
|
||||||
|
commit_sha=commit_sha,
|
||||||
|
content_bundle_manifest=_build_manifest(
|
||||||
|
bundle_context=bundle_context,
|
||||||
|
status=PublishingStatus.PUBLISH_COMMIT_CREATED,
|
||||||
|
validation_errors=[],
|
||||||
|
commit_sha=commit_sha,
|
||||||
|
),
|
||||||
|
status=PublishingStatus.PUBLISH_COMMIT_CREATED,
|
||||||
|
deployment_status="PENDING",
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
updated = repository.articles.update_status(
|
||||||
|
article_id=article.id,
|
||||||
|
status=ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
updated = repository.articles.update_publishing_status(
|
||||||
|
article_id=article.id,
|
||||||
|
publishing_status=PublishingStatus.PUBLISH_COMMIT_CREATED,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article.id,
|
||||||
|
event_type="PUBLISH_COMMIT_CREATED",
|
||||||
|
from_status=article.status,
|
||||||
|
to_status=ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
payload={
|
||||||
|
"publish_commit_id": str(publish_commit.id),
|
||||||
|
"repository_url": repository_url,
|
||||||
|
"branch": branch,
|
||||||
|
"commit_sha": commit_sha,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return PublishCommitCreateResponse(article=updated, publish_commit=publish_commit)
|
||||||
|
|
||||||
|
|
||||||
|
def get_publishing_status(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
) -> PublishingStatusResponse:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
latest_dry_run = repository.publish_commits.latest_for_article_with_statuses(
|
||||||
|
article_id,
|
||||||
|
statuses={PublishingStatus.PUBLISH_COMMIT_READY, PublishingStatus.PUBLISH_DRY_RUN_FAILED},
|
||||||
|
)
|
||||||
|
latest_publish_commit = repository.publish_commits.latest_for_article_with_statuses(
|
||||||
|
article_id,
|
||||||
|
statuses={PublishingStatus.PUBLISH_COMMIT_CREATED},
|
||||||
|
)
|
||||||
|
return PublishingStatusResponse(
|
||||||
|
article=article,
|
||||||
|
latest_dry_run=latest_dry_run,
|
||||||
|
latest_publish_commit=latest_publish_commit,
|
||||||
|
validation_label=_VALIDATION_LABEL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_publish_commits(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
) -> PublishCommitListResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
return PublishCommitListResponse(commits=repository.publish_commits.list_for_article(article_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_bundle_context(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article: ArticleSummary,
|
||||||
|
final_approval_event: object,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
target_site = repository.target_sites.get_by_id(article.target_site_id)
|
||||||
|
if target_site.active_script_config_version_id is None:
|
||||||
|
raise PermissionError("Active script config version is required for publishing.")
|
||||||
|
script_config_version = repository.script_config_versions.get_by_id(
|
||||||
|
target_site.active_script_config_version_id
|
||||||
|
)
|
||||||
|
draft = repository.article_drafts.latest_for_article(article.id)
|
||||||
|
if draft is None:
|
||||||
|
raise PermissionError("Latest draft is required for publishing.")
|
||||||
|
|
||||||
|
settings_payload = final_approval_event.payload.get("publishing_settings", {})
|
||||||
|
if not isinstance(settings_payload, dict):
|
||||||
|
settings_payload = {}
|
||||||
|
content_path = _string_value(settings_payload.get("content_path"))
|
||||||
|
if not content_path:
|
||||||
|
raise PermissionError("Final approval publishing settings are missing content_path.")
|
||||||
|
author = _string_value(settings_payload.get("author"))
|
||||||
|
if not author:
|
||||||
|
raise PermissionError("Final approval publishing settings are missing author.")
|
||||||
|
frontmatter_input = (
|
||||||
|
settings_payload.get("frontmatter")
|
||||||
|
if isinstance(settings_payload.get("frontmatter"), dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
|
||||||
|
slug = _slug_from_content_path(content_path) or _slugify(draft.title or article.working_title or "article")
|
||||||
|
content_rel_path = _apply_template(
|
||||||
|
target_site.publishing_rules.content_path_template,
|
||||||
|
{
|
||||||
|
"slug": slug,
|
||||||
|
"article_id": str(article.id),
|
||||||
|
"content_path": content_path.lstrip("/"),
|
||||||
|
"format": target_site.publishing_rules.content_format,
|
||||||
|
"language": article.language,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
frontmatter = _build_frontmatter(
|
||||||
|
frontmatter_mapping=target_site.publishing_rules.frontmatter_mapping,
|
||||||
|
frontmatter_input=frontmatter_input,
|
||||||
|
author=author,
|
||||||
|
draft=draft,
|
||||||
|
published_at=_now().isoformat(),
|
||||||
|
)
|
||||||
|
markdown_with_frontmatter = _compose_markdown(frontmatter, draft.body_markdown)
|
||||||
|
object_storage = open_object_storage_client()
|
||||||
|
assets = _collect_assets(
|
||||||
|
repository,
|
||||||
|
article_id=article.id,
|
||||||
|
slug=slug,
|
||||||
|
asset_path_template=target_site.publishing_rules.asset_path_template,
|
||||||
|
object_storage=object_storage,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"article": article,
|
||||||
|
"draft": draft,
|
||||||
|
"target_site": target_site,
|
||||||
|
"script_config_version": script_config_version,
|
||||||
|
"slug": slug,
|
||||||
|
"content_rel_path": content_rel_path,
|
||||||
|
"frontmatter": frontmatter,
|
||||||
|
"markdown_body": draft.body_markdown,
|
||||||
|
"markdown_with_frontmatter": markdown_with_frontmatter,
|
||||||
|
"assets": assets,
|
||||||
|
"repository_url": target_site.publishing_rules.repository_url,
|
||||||
|
"branch": target_site.publishing_rules.production_branch,
|
||||||
|
"content_format": target_site.publishing_rules.content_format,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_frontmatter(
|
||||||
|
*,
|
||||||
|
frontmatter_mapping: dict[str, Any],
|
||||||
|
frontmatter_input: dict[str, Any],
|
||||||
|
author: str,
|
||||||
|
draft: object,
|
||||||
|
published_at: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
source = {
|
||||||
|
**frontmatter_input,
|
||||||
|
"title": frontmatter_input.get("title") or getattr(draft, "title", None),
|
||||||
|
"meta_description": getattr(draft, "meta_description", None),
|
||||||
|
"author": author,
|
||||||
|
"published_at": published_at,
|
||||||
|
}
|
||||||
|
mapped: dict[str, Any] = {}
|
||||||
|
for output_key, source_key in frontmatter_mapping.items():
|
||||||
|
if not isinstance(output_key, str) or not output_key:
|
||||||
|
continue
|
||||||
|
if not isinstance(source_key, str) or not source_key:
|
||||||
|
continue
|
||||||
|
if source_key in source and source[source_key] is not None:
|
||||||
|
mapped[output_key] = source[source_key]
|
||||||
|
|
||||||
|
for key, value in frontmatter_input.items():
|
||||||
|
if key not in mapped:
|
||||||
|
mapped[key] = value
|
||||||
|
if "author" not in mapped:
|
||||||
|
mapped["author"] = author
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_assets(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
slug: str,
|
||||||
|
asset_path_template: str,
|
||||||
|
object_storage: object,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
assets = repository.assets.list_for_article(article_id)
|
||||||
|
output: list[dict[str, Any]] = []
|
||||||
|
for asset in assets:
|
||||||
|
if asset.status.value != "APPROVED":
|
||||||
|
continue
|
||||||
|
if not asset.file_url:
|
||||||
|
continue
|
||||||
|
source_file_name = _asset_filename(asset)
|
||||||
|
source_content = _load_asset_bytes(
|
||||||
|
asset=asset,
|
||||||
|
object_storage=object_storage,
|
||||||
|
)
|
||||||
|
if source_content is None:
|
||||||
|
raise PermissionError(f"Approved asset file is unavailable: {asset.file_url}")
|
||||||
|
target_path = _apply_template(
|
||||||
|
asset_path_template,
|
||||||
|
{
|
||||||
|
"slug": slug,
|
||||||
|
"filename": source_file_name,
|
||||||
|
"article_id": str(article_id),
|
||||||
|
"asset_id": str(asset.id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
output.append(
|
||||||
|
{
|
||||||
|
"asset_id": str(asset.id),
|
||||||
|
"asset_type": asset.asset_type.value,
|
||||||
|
"source_url": asset.file_url,
|
||||||
|
"source_content": source_content,
|
||||||
|
"target_path": target_path,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def _create_and_push_commit(bundle_context: dict[str, Any]) -> str:
|
||||||
|
repository_url = bundle_context["repository_url"]
|
||||||
|
branch = bundle_context["branch"]
|
||||||
|
with tempfile.TemporaryDirectory(prefix="publish-commit-") as tmp_dir:
|
||||||
|
workspace = Path(tmp_dir) / "site"
|
||||||
|
_run_cmd(
|
||||||
|
["git", "clone", "--branch", branch, "--single-branch", repository_url, str(workspace)],
|
||||||
|
cwd=None,
|
||||||
|
error_prefix="GIT_CHECKOUT_FAILED",
|
||||||
|
)
|
||||||
|
runtime_dir = workspace / _RUNTIME_DIR
|
||||||
|
runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
script_config = bundle_context["script_config_version"]
|
||||||
|
(workspace / _PUBLISHING_YAML_PATH).write_text(
|
||||||
|
str(script_config["publishing_yaml"]),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(workspace / _TRANSFORM_SCRIPT_PATH).write_text(
|
||||||
|
str(script_config["transform_script"]),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
transform_assets = [
|
||||||
|
{
|
||||||
|
"asset_id": asset["asset_id"],
|
||||||
|
"asset_type": asset["asset_type"],
|
||||||
|
"source_url": asset["source_url"],
|
||||||
|
"target_path": asset["target_path"],
|
||||||
|
}
|
||||||
|
for asset in bundle_context["assets"]
|
||||||
|
]
|
||||||
|
transform_input = {
|
||||||
|
"frontmatter": bundle_context["frontmatter"],
|
||||||
|
"body": bundle_context["markdown_body"],
|
||||||
|
"assets": transform_assets,
|
||||||
|
"content_path": bundle_context["content_rel_path"],
|
||||||
|
}
|
||||||
|
(workspace / _TRANSFORM_INPUT_PATH).write_text(
|
||||||
|
json.dumps(transform_input, ensure_ascii=True, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(workspace / _TRANSFORM_RUNNER_PATH).write_text(
|
||||||
|
_transform_runner_script(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
_run_cmd(
|
||||||
|
[
|
||||||
|
"node",
|
||||||
|
_TRANSFORM_RUNNER_PATH,
|
||||||
|
_TRANSFORM_SCRIPT_PATH,
|
||||||
|
_TRANSFORM_INPUT_PATH,
|
||||||
|
_TRANSFORM_OUTPUT_PATH,
|
||||||
|
],
|
||||||
|
cwd=workspace,
|
||||||
|
error_prefix="PUBLISH_DRY_RUN_FAILED",
|
||||||
|
)
|
||||||
|
|
||||||
|
content_file = workspace / bundle_context["content_rel_path"]
|
||||||
|
content_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
content_file.write_text(bundle_context["markdown_with_frontmatter"], encoding="utf-8")
|
||||||
|
for asset in bundle_context["assets"]:
|
||||||
|
target_path = workspace / str(asset["target_path"])
|
||||||
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target_path.write_bytes(bytes(asset["source_content"]))
|
||||||
|
shutil.rmtree(runtime_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
_run_cmd(
|
||||||
|
["git", "config", "user.name", "Pipeline Bot"],
|
||||||
|
cwd=workspace,
|
||||||
|
error_prefix="GIT_COMMIT_FAILED",
|
||||||
|
)
|
||||||
|
_run_cmd(
|
||||||
|
["git", "config", "user.email", "pipeline-bot@example.com"],
|
||||||
|
cwd=workspace,
|
||||||
|
error_prefix="GIT_COMMIT_FAILED",
|
||||||
|
)
|
||||||
|
_run_cmd(["git", "add", "."], cwd=workspace, error_prefix="GIT_COMMIT_FAILED")
|
||||||
|
commit_message = (
|
||||||
|
f"Publish article {bundle_context['article'].id}: {bundle_context['draft'].title}"
|
||||||
|
)
|
||||||
|
_run_cmd(
|
||||||
|
["git", "commit", "--allow-empty", "-m", commit_message],
|
||||||
|
cwd=workspace,
|
||||||
|
error_prefix="GIT_COMMIT_FAILED",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_run_cmd(
|
||||||
|
["git", "push", "origin", branch],
|
||||||
|
cwd=workspace,
|
||||||
|
error_prefix="GIT_PUSH_NON_FAST_FORWARD",
|
||||||
|
)
|
||||||
|
except RuntimeError as error:
|
||||||
|
if "non-fast-forward" in str(error) or "[rejected]" in str(error):
|
||||||
|
raise PermissionError(
|
||||||
|
"Non-fast-forward push rejected. Re-run dry run and retry commit."
|
||||||
|
) from error
|
||||||
|
raise
|
||||||
|
|
||||||
|
commit_sha = _run_cmd(
|
||||||
|
["git", "rev-parse", "HEAD"],
|
||||||
|
cwd=workspace,
|
||||||
|
error_prefix="GIT_COMMIT_FAILED",
|
||||||
|
).strip()
|
||||||
|
return commit_sha
|
||||||
|
|
||||||
|
|
||||||
|
def _update_article_for_dry_run_result(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article: ArticleSummary,
|
||||||
|
content_shape_valid: bool,
|
||||||
|
actor_user_id: UUID,
|
||||||
|
validation_errors: list[str],
|
||||||
|
created_at: datetime,
|
||||||
|
) -> ArticleSummary:
|
||||||
|
if content_shape_valid:
|
||||||
|
updated = repository.articles.update_status(
|
||||||
|
article_id=article.id,
|
||||||
|
status=ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
|
||||||
|
updated_at=created_at,
|
||||||
|
)
|
||||||
|
updated = repository.articles.update_publishing_status(
|
||||||
|
article_id=article.id,
|
||||||
|
publishing_status=PublishingStatus.PUBLISH_COMMIT_READY,
|
||||||
|
updated_at=created_at,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article.id,
|
||||||
|
event_type="PUBLISH_DRY_RUN_SUCCEEDED",
|
||||||
|
from_status=article.status,
|
||||||
|
to_status=ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
payload={"validation_label": _VALIDATION_LABEL},
|
||||||
|
created_at=created_at,
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
updated = repository.articles.update_status(
|
||||||
|
article_id=article.id,
|
||||||
|
status=ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
|
||||||
|
updated_at=created_at,
|
||||||
|
)
|
||||||
|
updated = repository.articles.update_publishing_status(
|
||||||
|
article_id=article.id,
|
||||||
|
publishing_status=PublishingStatus.PUBLISH_DRY_RUN_FAILED,
|
||||||
|
updated_at=created_at,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article.id,
|
||||||
|
event_type="PUBLISH_DRY_RUN_FAILED",
|
||||||
|
from_status=article.status,
|
||||||
|
to_status=ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
payload={
|
||||||
|
"validation_label": _VALIDATION_LABEL,
|
||||||
|
"errors": validation_errors,
|
||||||
|
},
|
||||||
|
created_at=created_at,
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def _record_publish_failure(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article: ArticleSummary,
|
||||||
|
actor_user_id: UUID,
|
||||||
|
repository_url: str,
|
||||||
|
branch: str,
|
||||||
|
previous_manifest: dict[str, Any],
|
||||||
|
detail: str,
|
||||||
|
) -> None:
|
||||||
|
now = _now()
|
||||||
|
failed_manifest = dict(previous_manifest)
|
||||||
|
failed_manifest["failure"] = detail
|
||||||
|
repository.publish_commits.create(
|
||||||
|
article_id=article.id,
|
||||||
|
target_site_id=article.target_site_id,
|
||||||
|
repository_url=repository_url,
|
||||||
|
branch=branch,
|
||||||
|
commit_sha=None,
|
||||||
|
content_bundle_manifest=failed_manifest,
|
||||||
|
status=PublishingStatus.PUBLISH_VERIFICATION_FAILED,
|
||||||
|
deployment_status="FAILED",
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.update_publishing_status(
|
||||||
|
article_id=article.id,
|
||||||
|
publishing_status=PublishingStatus.PUBLISH_VERIFICATION_FAILED,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article.id,
|
||||||
|
event_type="PUBLISH_COMMIT_FAILED",
|
||||||
|
from_status=article.status,
|
||||||
|
to_status=article.status,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
payload={"detail": detail},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_manifest(
|
||||||
|
*,
|
||||||
|
bundle_context: dict[str, Any],
|
||||||
|
status: PublishingStatus,
|
||||||
|
validation_errors: list[str],
|
||||||
|
commit_sha: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
base_head_sha = _resolve_remote_branch_head_sha(
|
||||||
|
bundle_context["repository_url"], bundle_context["branch"]
|
||||||
|
)
|
||||||
|
script_config_version = bundle_context["script_config_version"]
|
||||||
|
return {
|
||||||
|
"status": status.value,
|
||||||
|
"generated_at": _now().isoformat(),
|
||||||
|
"validation": {
|
||||||
|
"label": _VALIDATION_LABEL,
|
||||||
|
"content_shape_valid": len(validation_errors) == 0,
|
||||||
|
"errors": validation_errors,
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"format": bundle_context["content_format"],
|
||||||
|
"path": bundle_context["content_rel_path"],
|
||||||
|
"slug": bundle_context["slug"],
|
||||||
|
},
|
||||||
|
"frontmatter": bundle_context["frontmatter"],
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"asset_id": asset["asset_id"],
|
||||||
|
"asset_type": asset["asset_type"],
|
||||||
|
"source_url": asset["source_url"],
|
||||||
|
"target_path": asset["target_path"],
|
||||||
|
}
|
||||||
|
for asset in bundle_context["assets"]
|
||||||
|
],
|
||||||
|
"config_version": {
|
||||||
|
"version_id": str(script_config_version["id"]),
|
||||||
|
"version": int(script_config_version["version"]),
|
||||||
|
"publishing_yaml_hash": str(script_config_version["publishing_yaml_hash"]),
|
||||||
|
"transform_script_hash": str(script_config_version["transform_script_hash"]),
|
||||||
|
},
|
||||||
|
"git": {
|
||||||
|
"repository_url": bundle_context["repository_url"],
|
||||||
|
"branch": bundle_context["branch"],
|
||||||
|
"base_head_sha": base_head_sha,
|
||||||
|
"commit_sha": commit_sha,
|
||||||
|
},
|
||||||
|
"draft_version": int(bundle_context["draft"].version),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_content_shape(markdown: str) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
if not markdown.strip():
|
||||||
|
errors.append("Markdown body is empty.")
|
||||||
|
if re.search(r"^#{1,6}\s+\S", markdown, re.MULTILINE) is None:
|
||||||
|
errors.append("Markdown body must include at least one heading.")
|
||||||
|
if markdown.count("```") % 2 != 0:
|
||||||
|
errors.append("Markdown body has unbalanced fenced code blocks.")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def _compose_markdown(frontmatter: dict[str, Any], markdown_body: str) -> str:
|
||||||
|
lines = ["---"]
|
||||||
|
for key in sorted(frontmatter):
|
||||||
|
lines.append(f"{key}: {json.dumps(frontmatter[key], ensure_ascii=True)}")
|
||||||
|
lines.extend(["---", "", markdown_body.strip(), ""])
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_remote_branch_head_sha(repository_url: str, branch: str) -> str | None:
|
||||||
|
output = _run_cmd(
|
||||||
|
["git", "ls-remote", repository_url, f"refs/heads/{branch}"],
|
||||||
|
cwd=None,
|
||||||
|
error_prefix="GIT_CHECKOUT_FAILED",
|
||||||
|
)
|
||||||
|
line = output.strip()
|
||||||
|
if not line:
|
||||||
|
return None
|
||||||
|
return line.split("\t", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cmd(
|
||||||
|
command: list[str],
|
||||||
|
*,
|
||||||
|
cwd: Path | None,
|
||||||
|
error_prefix: str,
|
||||||
|
) -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
cwd=str(cwd) if cwd is not None else None,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
message = result.stderr.strip() or result.stdout.strip() or "command failed"
|
||||||
|
raise RuntimeError(f"{error_prefix}: {message}")
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_template(template: str, values: dict[str, Any]) -> str:
|
||||||
|
class _SafeValues(dict[str, Any]):
|
||||||
|
def __missing__(self, key: str) -> str:
|
||||||
|
return "{" + key + "}"
|
||||||
|
|
||||||
|
rendered = template.format_map(_SafeValues(values))
|
||||||
|
return rendered.lstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _local_path_from_file_url(file_url: str) -> Path | None:
|
||||||
|
parsed = urlparse(file_url)
|
||||||
|
if parsed.scheme != "file":
|
||||||
|
return None
|
||||||
|
return Path(parsed.path)
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_filename(asset: object) -> str:
|
||||||
|
source_path = _local_path_from_file_url(_string_value(getattr(asset, "file_url", None)))
|
||||||
|
if source_path is not None:
|
||||||
|
return source_path.name
|
||||||
|
object_key = _string_value(getattr(asset, "object_key", None))
|
||||||
|
if object_key:
|
||||||
|
return Path(object_key).name
|
||||||
|
return "asset.bin"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_asset_bytes(*, asset: object, object_storage: object) -> bytes | None:
|
||||||
|
file_url = _string_value(getattr(asset, "file_url", None))
|
||||||
|
local_path = _local_path_from_file_url(file_url)
|
||||||
|
if local_path is not None:
|
||||||
|
if not local_path.exists():
|
||||||
|
return None
|
||||||
|
return local_path.read_bytes()
|
||||||
|
|
||||||
|
object_key = _string_value(getattr(asset, "object_key", None))
|
||||||
|
parsed = urlparse(file_url)
|
||||||
|
if parsed.scheme == "s3" and not object_key:
|
||||||
|
object_key = parsed.path.lstrip("/")
|
||||||
|
if not object_key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return object_storage.get_bytes(object_key=object_key)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_final_approval(repository: object, *, article_id: UUID) -> object:
|
||||||
|
events = repository.articles.list_workflow_events(article_id)
|
||||||
|
for event in reversed(events):
|
||||||
|
if event.event_type == _FINAL_APPROVAL_EVENT:
|
||||||
|
return event
|
||||||
|
raise PermissionError("Final approval is required before publishing.")
|
||||||
|
|
||||||
|
|
||||||
|
def _slug_from_content_path(content_path: str) -> str:
|
||||||
|
value = content_path.strip().strip("/")
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
tail = value.split("/")[-1]
|
||||||
|
if "." in tail:
|
||||||
|
tail = tail.rsplit(".", 1)[0]
|
||||||
|
return _slugify(tail)
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(value: str) -> str:
|
||||||
|
normalized = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-")
|
||||||
|
return normalized or "article"
|
||||||
|
|
||||||
|
|
||||||
|
def _string_value(value: Any) -> str:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _transform_runner_script() -> str:
|
||||||
|
return """
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const [scriptPath, inputPath, outputPath] = process.argv.slice(2);
|
||||||
|
const scriptUrl = pathToFileURL(scriptPath).href;
|
||||||
|
const moduleRef = await import(scriptUrl + `?t=${Date.now()}`);
|
||||||
|
const transform =
|
||||||
|
typeof moduleRef.transformArticle === "function"
|
||||||
|
? moduleRef.transformArticle
|
||||||
|
: typeof moduleRef.default === "function"
|
||||||
|
? moduleRef.default
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!transform) {
|
||||||
|
throw new Error("Transform script must export transformArticle(article).");
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = readFileSync(inputPath, "utf8");
|
||||||
|
const article = JSON.parse(raw);
|
||||||
|
const transformed = await transform(article);
|
||||||
|
const output = transformed ?? article;
|
||||||
|
writeFileSync(outputPath, JSON.stringify(output, null, 2), "utf8");
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from src.domain.contracts import (
|
||||||
|
AgentJobStatus,
|
||||||
|
AgentJobType,
|
||||||
|
ArticleWorkflowStatus,
|
||||||
|
PlanReviewStatus,
|
||||||
|
ResearchListResponse,
|
||||||
|
ResearchStartResponse,
|
||||||
|
)
|
||||||
|
from src.infrastructure.object_storage import ObjectStorageClient
|
||||||
|
|
||||||
|
|
||||||
|
def start_research_run(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
object_storage: ObjectStorageClient,
|
||||||
|
) -> ResearchStartResponse:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
approved_plan = _approved_plan(repository, article_id)
|
||||||
|
if approved_plan is None:
|
||||||
|
raise PermissionError("Plan must be approved before research can start")
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
if article.status != ArticleWorkflowStatus.RESEARCH_RUNNING:
|
||||||
|
article = repository.articles.update_status(
|
||||||
|
article_id=article_id,
|
||||||
|
status=ArticleWorkflowStatus.RESEARCH_RUNNING,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
job = repository.agent_jobs.create(
|
||||||
|
article_id=article_id,
|
||||||
|
parent_job_id=None,
|
||||||
|
attempt=1,
|
||||||
|
job_type=AgentJobType.RESEARCH,
|
||||||
|
agent_profile="fake-research-fetch",
|
||||||
|
status=AgentJobStatus.SUCCEEDED,
|
||||||
|
input_files=[{"path": "inputs/approved-plan.json", "content_hash": None}],
|
||||||
|
queued_at=now,
|
||||||
|
)
|
||||||
|
run_id = uuid4()
|
||||||
|
s3_prefix = f"research/{article_id}/{run_id}"
|
||||||
|
artifacts = _build_fake_research_artifacts(
|
||||||
|
article=article,
|
||||||
|
plan=approved_plan,
|
||||||
|
s3_prefix=s3_prefix,
|
||||||
|
object_storage=object_storage,
|
||||||
|
retrieved_at=now,
|
||||||
|
)
|
||||||
|
manifest = repository.research_manifests.create(
|
||||||
|
article_id=article_id,
|
||||||
|
agent_job_id=job.id,
|
||||||
|
s3_prefix=s3_prefix,
|
||||||
|
artifacts=artifacts,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
job = repository.agent_jobs.complete(
|
||||||
|
job_id=job.id,
|
||||||
|
status=AgentJobStatus.SUCCEEDED,
|
||||||
|
workspace_path=None,
|
||||||
|
output_files=[{"path": artifact["object_key"]} for artifact in artifacts],
|
||||||
|
payload={},
|
||||||
|
error_category=None,
|
||||||
|
error_message=None,
|
||||||
|
stdout="fake research fetch completed\n",
|
||||||
|
stderr="",
|
||||||
|
exit_code=0,
|
||||||
|
duration_ms=0,
|
||||||
|
finished_at=now,
|
||||||
|
)
|
||||||
|
return ResearchStartResponse(article=article, job=job, manifest=manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def list_research_runs(repository: object, *, article_id: UUID) -> ResearchListResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
return ResearchListResponse(
|
||||||
|
manifests=repository.research_manifests.list_for_article(article_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _approved_plan(repository: object, article_id: UUID) -> object | None:
|
||||||
|
for plan in reversed(repository.article_plans.list_for_article(article_id)):
|
||||||
|
if plan.status == PlanReviewStatus.APPROVED:
|
||||||
|
return plan
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_fake_research_artifacts(
|
||||||
|
*,
|
||||||
|
article: object,
|
||||||
|
plan: object,
|
||||||
|
s3_prefix: str,
|
||||||
|
object_storage: ObjectStorageClient,
|
||||||
|
retrieved_at: datetime,
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
sources = [
|
||||||
|
{
|
||||||
|
"url": "https://example.com/research/content-governance",
|
||||||
|
"title": "Content governance implementation guide",
|
||||||
|
"source_type": "primary_reference",
|
||||||
|
"summary": "Governance programs need explicit review gates and source evidence.",
|
||||||
|
"section": "A source-section snapshot about governance workflow controls.",
|
||||||
|
"ip": "93.184.216.34",
|
||||||
|
"whois": "Example Domain Registry",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://example.org/research/evidence-standards",
|
||||||
|
"title": "Evidence standards for editorial claims",
|
||||||
|
"source_type": "standards_reference",
|
||||||
|
"summary": "Editorial claims should be linked to durable source artifacts.",
|
||||||
|
"section": "A source-section snapshot about evidence standards and claim review.",
|
||||||
|
"ip": "93.184.216.34",
|
||||||
|
"whois": "Example Organization Registry",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
artifacts: list[dict[str, object]] = []
|
||||||
|
for index, source in enumerate(sources, start=1):
|
||||||
|
parsed = urlparse(str(source["url"]))
|
||||||
|
base_key = f"{s3_prefix}/source-{index}"
|
||||||
|
content_key = f"{base_key}/section.json"
|
||||||
|
metadata_key = f"{base_key}/metadata.json"
|
||||||
|
payload = {
|
||||||
|
"article_id": str(article.id),
|
||||||
|
"plan_id": str(plan.id),
|
||||||
|
"plan_version": plan.version,
|
||||||
|
"source_url": source["url"],
|
||||||
|
"section_snapshot": source["section"],
|
||||||
|
"retrieved_at": retrieved_at.isoformat(),
|
||||||
|
}
|
||||||
|
metadata = {
|
||||||
|
"source_title": source["title"],
|
||||||
|
"domain": parsed.netloc,
|
||||||
|
"source_type": source["source_type"],
|
||||||
|
"summary": source["summary"],
|
||||||
|
"retrieved_at": retrieved_at.isoformat(),
|
||||||
|
"artifact_link": content_key,
|
||||||
|
"metadata_object_key": metadata_key,
|
||||||
|
"search_path": [
|
||||||
|
article.primary_keyword or article.working_title or "article research",
|
||||||
|
str(source["title"]),
|
||||||
|
],
|
||||||
|
"selection_criteria": [
|
||||||
|
"Matches approved plan evidence needs",
|
||||||
|
"Deterministic fake fixture for demo and tests",
|
||||||
|
],
|
||||||
|
"head_metadata": {
|
||||||
|
"title": source["title"],
|
||||||
|
"description": source["summary"],
|
||||||
|
"canonical": source["url"],
|
||||||
|
},
|
||||||
|
"server_ip_address": source["ip"],
|
||||||
|
"whois_owner": source["whois"],
|
||||||
|
}
|
||||||
|
content = json.dumps(payload, sort_keys=True, indent=2)
|
||||||
|
metadata_content = json.dumps(metadata, sort_keys=True, indent=2)
|
||||||
|
object_storage.put_text(object_key=content_key, content=content)
|
||||||
|
object_storage.put_text(object_key=metadata_key, content=metadata_content)
|
||||||
|
artifacts.append(
|
||||||
|
{
|
||||||
|
"artifact_type": "source_section_snapshot",
|
||||||
|
"source_url": source["url"],
|
||||||
|
"object_key": content_key,
|
||||||
|
"content_hash": hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
||||||
|
"metadata": metadata,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return artifacts
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import NAMESPACE_URL, UUID, uuid5
|
||||||
|
|
||||||
|
from src.application.drafts import update_draft
|
||||||
|
from src.domain.contracts import (
|
||||||
|
ArticleWorkflowStatus,
|
||||||
|
ContentReviewKind,
|
||||||
|
ContentReviewReportSummary,
|
||||||
|
ContentReviewSuggestionSummary,
|
||||||
|
DraftSummary,
|
||||||
|
DraftUpdateRequest,
|
||||||
|
FinalReviewIssueSummary,
|
||||||
|
FinalReviewIssuesResponse,
|
||||||
|
LanguageReviewReportResponse,
|
||||||
|
LanguageReviewRunResponse,
|
||||||
|
ReviewSuggestionActionResponse,
|
||||||
|
ReviewSuggestionEditRequest,
|
||||||
|
ReviewSuggestionStatus,
|
||||||
|
SeoReviewReportResponse,
|
||||||
|
SeoReviewRunResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SEO_RULES = {
|
||||||
|
"title_min": 30,
|
||||||
|
"title_max": 60,
|
||||||
|
"meta_title_min": 30,
|
||||||
|
"meta_title_max": 60,
|
||||||
|
"meta_description_min": 120,
|
||||||
|
"meta_description_max": 160,
|
||||||
|
"schema_type": "Article",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_LANGUAGE_RULES = {
|
||||||
|
"max_sentence_words": 24,
|
||||||
|
"forbidden_phrases": ["best ever", "guaranteed"],
|
||||||
|
"brand_tone": "neutral",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_seo_review(repository: object, *, article_id: UUID) -> SeoReviewRunResponse:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
draft = _require_latest_draft(repository, article_id=article_id)
|
||||||
|
site = repository.target_sites.get_by_id(article.target_site_id)
|
||||||
|
rules = _resolve_seo_rules(site.seo_rules)
|
||||||
|
issues = _build_seo_issues(
|
||||||
|
article_id=article_id,
|
||||||
|
title=draft.title,
|
||||||
|
meta_title=draft.meta_title or draft.title,
|
||||||
|
meta_description=draft.meta_description or "",
|
||||||
|
body_markdown=draft.body_markdown,
|
||||||
|
primary_keyword=article.primary_keyword or "",
|
||||||
|
rules=rules,
|
||||||
|
)
|
||||||
|
recommended_title = _recommended_title(
|
||||||
|
title=draft.title,
|
||||||
|
primary_keyword=article.primary_keyword or "",
|
||||||
|
)
|
||||||
|
recommended_slug = _slugify(recommended_title)
|
||||||
|
score = max(0, 100 - len(issues) * 12)
|
||||||
|
now = _now()
|
||||||
|
report = repository.content_reviews.create_report(
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=ContentReviewKind.SEO,
|
||||||
|
draft_id=draft.id,
|
||||||
|
score=score,
|
||||||
|
recommended_slug=recommended_slug,
|
||||||
|
recommended_title=recommended_title,
|
||||||
|
schema_json={
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": str(rules.get("schema_type", "Article")),
|
||||||
|
"headline": recommended_title,
|
||||||
|
"inLanguage": article.language,
|
||||||
|
},
|
||||||
|
rules_snapshot=rules,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
_upsert_report_suggestions(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
report_id=report.id,
|
||||||
|
review_kind=ContentReviewKind.SEO,
|
||||||
|
issues=issues,
|
||||||
|
timestamp=now,
|
||||||
|
)
|
||||||
|
latest = repository.content_reviews.get_report(
|
||||||
|
article_id=article_id,
|
||||||
|
report_id=report.id,
|
||||||
|
)
|
||||||
|
_mark_review_ready(repository, article_id=article_id, event_type="SEO_REVIEW_COMPLETED", count=len(latest.issues))
|
||||||
|
return SeoReviewRunResponse(report=latest)
|
||||||
|
|
||||||
|
|
||||||
|
def get_seo_report(repository: object, *, article_id: UUID) -> SeoReviewReportResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
report = repository.content_reviews.latest_report(
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=ContentReviewKind.SEO,
|
||||||
|
)
|
||||||
|
if report is None:
|
||||||
|
raise LookupError(f"SEO report not found for article: {article_id}")
|
||||||
|
return SeoReviewReportResponse(report=report)
|
||||||
|
|
||||||
|
|
||||||
|
def run_language_review(repository: object, *, article_id: UUID) -> LanguageReviewRunResponse:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
draft = _require_latest_draft(repository, article_id=article_id)
|
||||||
|
site = repository.target_sites.get_by_id(article.target_site_id)
|
||||||
|
rules = _resolve_language_rules(site.seo_rules, brand_voice=site.brand_voice)
|
||||||
|
issues = _build_language_issues(
|
||||||
|
article_id=article_id,
|
||||||
|
body_markdown=draft.body_markdown,
|
||||||
|
rules=rules,
|
||||||
|
)
|
||||||
|
score = max(0, 100 - len(issues) * 14)
|
||||||
|
now = _now()
|
||||||
|
report = repository.content_reviews.create_report(
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=ContentReviewKind.LANGUAGE,
|
||||||
|
draft_id=draft.id,
|
||||||
|
score=score,
|
||||||
|
recommended_slug=draft.slug,
|
||||||
|
recommended_title=draft.title,
|
||||||
|
schema_json={
|
||||||
|
"tone": rules.get("brand_tone"),
|
||||||
|
"language": article.language,
|
||||||
|
"max_sentence_words": rules.get("max_sentence_words"),
|
||||||
|
},
|
||||||
|
rules_snapshot=rules,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
_upsert_report_suggestions(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
report_id=report.id,
|
||||||
|
review_kind=ContentReviewKind.LANGUAGE,
|
||||||
|
issues=issues,
|
||||||
|
timestamp=now,
|
||||||
|
)
|
||||||
|
latest = repository.content_reviews.get_report(
|
||||||
|
article_id=article_id,
|
||||||
|
report_id=report.id,
|
||||||
|
)
|
||||||
|
_mark_review_ready(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
event_type="LANGUAGE_REVIEW_COMPLETED",
|
||||||
|
count=len(latest.issues),
|
||||||
|
)
|
||||||
|
return LanguageReviewRunResponse(report=latest)
|
||||||
|
|
||||||
|
|
||||||
|
def get_language_report(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
) -> LanguageReviewReportResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
report = repository.content_reviews.latest_report(
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=ContentReviewKind.LANGUAGE,
|
||||||
|
)
|
||||||
|
if report is None:
|
||||||
|
raise LookupError(f"Language report not found for article: {article_id}")
|
||||||
|
return LanguageReviewReportResponse(report=report)
|
||||||
|
|
||||||
|
|
||||||
|
def accept_suggestion(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
review_kind: ContentReviewKind,
|
||||||
|
suggestion_id: UUID,
|
||||||
|
) -> ReviewSuggestionActionResponse:
|
||||||
|
suggestion = repository.content_reviews.get_suggestion(
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=review_kind,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
)
|
||||||
|
draft = _apply_suggestion_to_draft(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
suggestion=suggestion,
|
||||||
|
override_text=None,
|
||||||
|
)
|
||||||
|
updated = repository.content_reviews.update_suggestion(
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=review_kind,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
status=ReviewSuggestionStatus.ACCEPTED,
|
||||||
|
applied_text=None,
|
||||||
|
updated_at=_now(),
|
||||||
|
)
|
||||||
|
return ReviewSuggestionActionResponse(suggestion=updated, draft=draft)
|
||||||
|
|
||||||
|
|
||||||
|
def reject_suggestion(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
review_kind: ContentReviewKind,
|
||||||
|
suggestion_id: UUID,
|
||||||
|
) -> ReviewSuggestionActionResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
updated = repository.content_reviews.update_suggestion(
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=review_kind,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
status=ReviewSuggestionStatus.REJECTED,
|
||||||
|
applied_text=None,
|
||||||
|
updated_at=_now(),
|
||||||
|
)
|
||||||
|
return ReviewSuggestionActionResponse(suggestion=updated, draft=None)
|
||||||
|
|
||||||
|
|
||||||
|
def edit_suggestion(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
review_kind: ContentReviewKind,
|
||||||
|
suggestion_id: UUID,
|
||||||
|
request: ReviewSuggestionEditRequest,
|
||||||
|
) -> ReviewSuggestionActionResponse:
|
||||||
|
suggestion = repository.content_reviews.get_suggestion(
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=review_kind,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
)
|
||||||
|
draft = _apply_suggestion_to_draft(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
suggestion=suggestion,
|
||||||
|
override_text=request.applied_text,
|
||||||
|
)
|
||||||
|
updated = repository.content_reviews.update_suggestion(
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=review_kind,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
status=ReviewSuggestionStatus.EDITED,
|
||||||
|
applied_text=request.applied_text,
|
||||||
|
updated_at=_now(),
|
||||||
|
)
|
||||||
|
return ReviewSuggestionActionResponse(suggestion=updated, draft=draft)
|
||||||
|
|
||||||
|
|
||||||
|
def get_final_review_issues(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
) -> FinalReviewIssuesResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
unresolved = repository.content_reviews.list_unresolved_for_article(article_id=article_id)
|
||||||
|
return FinalReviewIssuesResponse(
|
||||||
|
article_id=article_id,
|
||||||
|
unresolved_count=len(unresolved),
|
||||||
|
unresolved_issues=[
|
||||||
|
FinalReviewIssueSummary(
|
||||||
|
review_kind=item.review_kind,
|
||||||
|
suggestion_id=item.id,
|
||||||
|
severity=item.severity,
|
||||||
|
location=item.location,
|
||||||
|
message=item.message,
|
||||||
|
status=item.status,
|
||||||
|
)
|
||||||
|
for item in unresolved
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_latest_draft(repository: object, *, article_id: UUID) -> DraftSummary:
|
||||||
|
draft = repository.article_drafts.latest_for_article(article_id)
|
||||||
|
if draft is None:
|
||||||
|
raise PermissionError("Assembled draft is required before review")
|
||||||
|
return draft
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_seo_rules(seo_rules: dict[str, object]) -> dict[str, object]:
|
||||||
|
overrides = seo_rules.get("seo_review", {}) if isinstance(seo_rules, dict) else {}
|
||||||
|
result = dict(DEFAULT_SEO_RULES)
|
||||||
|
if isinstance(overrides, dict):
|
||||||
|
result.update(overrides)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_language_rules(
|
||||||
|
seo_rules: dict[str, object],
|
||||||
|
*,
|
||||||
|
brand_voice: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
overrides = seo_rules.get("language_review", {}) if isinstance(seo_rules, dict) else {}
|
||||||
|
result = dict(DEFAULT_LANGUAGE_RULES)
|
||||||
|
if isinstance(overrides, dict):
|
||||||
|
result.update(overrides)
|
||||||
|
if not result.get("brand_tone"):
|
||||||
|
result["brand_tone"] = brand_voice
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _build_seo_issues(
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
title: str,
|
||||||
|
meta_title: str,
|
||||||
|
meta_description: str,
|
||||||
|
body_markdown: str,
|
||||||
|
primary_keyword: str,
|
||||||
|
rules: dict[str, object],
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
issues: list[dict[str, object]] = []
|
||||||
|
title_min = int(rules.get("meta_title_min", 30))
|
||||||
|
title_max = int(rules.get("meta_title_max", 60))
|
||||||
|
if len(meta_title) < title_min or len(meta_title) > title_max:
|
||||||
|
suggested = _trim(meta_title or title, min_len=title_min, max_len=title_max)
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"id": "meta-title-length",
|
||||||
|
"severity": "HIGH",
|
||||||
|
"location": "meta_title",
|
||||||
|
"message": f"Meta title length should be between {title_min} and {title_max} characters.",
|
||||||
|
"suggested_fix": f"Use a {title_min}-{title_max} character meta title.",
|
||||||
|
"suggested_rewrite": suggested,
|
||||||
|
"patch": {"field": "meta_title", "value": suggested},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
desc_min = int(rules.get("meta_description_min", 120))
|
||||||
|
desc_max = int(rules.get("meta_description_max", 160))
|
||||||
|
if len(meta_description) < desc_min or len(meta_description) > desc_max:
|
||||||
|
suggested_description = _trim(meta_description or title, min_len=desc_min, max_len=desc_max)
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"id": "meta-description-length",
|
||||||
|
"severity": "HIGH",
|
||||||
|
"location": "meta_description",
|
||||||
|
"message": f"Meta description length should be between {desc_min} and {desc_max} characters.",
|
||||||
|
"suggested_fix": "Adjust meta description length to fit SEO rules.",
|
||||||
|
"suggested_rewrite": suggested_description,
|
||||||
|
"patch": {"field": "meta_description", "value": suggested_description},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if primary_keyword:
|
||||||
|
normalized_keyword = primary_keyword.lower()
|
||||||
|
if normalized_keyword not in body_markdown.lower():
|
||||||
|
append_sentence = f" This guide addresses {primary_keyword} with concrete steps."
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"id": "keyword-placement",
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"location": "body_markdown",
|
||||||
|
"message": "Primary keyword is missing from draft body.",
|
||||||
|
"suggested_fix": "Add primary keyword to introduction paragraph.",
|
||||||
|
"suggested_rewrite": append_sentence.strip(),
|
||||||
|
"patch": {
|
||||||
|
"field": "body_append",
|
||||||
|
"value": append_sentence,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if "](/" not in body_markdown and "](/" not in body_markdown:
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"id": "internal-link",
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"location": "body_markdown",
|
||||||
|
"message": "No internal links found in the draft.",
|
||||||
|
"suggested_fix": "Add at least one internal link to a related page.",
|
||||||
|
"suggested_rewrite": "Add [related guide](/guides/related-topic) in the intro section.",
|
||||||
|
"patch": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
headings = re.findall(r"^##\s+(.+)$", body_markdown, flags=re.MULTILINE)
|
||||||
|
duplicate = _first_duplicate(headings)
|
||||||
|
if duplicate:
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"id": "duplicate-h2",
|
||||||
|
"severity": "LOW",
|
||||||
|
"location": "body_markdown",
|
||||||
|
"message": f"Duplicate H2 heading detected: {duplicate}",
|
||||||
|
"suggested_fix": "Rename duplicate headings to keep a clear document structure.",
|
||||||
|
"suggested_rewrite": None,
|
||||||
|
"patch": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
issues.sort(key=lambda item: str(item["id"]))
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _build_language_issues(
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
body_markdown: str,
|
||||||
|
rules: dict[str, object],
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
del article_id
|
||||||
|
issues: list[dict[str, object]] = []
|
||||||
|
max_sentence_words = int(rules.get("max_sentence_words", 24))
|
||||||
|
first_long_sentence = _first_sentence_longer_than(body_markdown, max_sentence_words)
|
||||||
|
if first_long_sentence:
|
||||||
|
rewrite = _shorten_sentence(first_long_sentence, max_words=max_sentence_words)
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"id": "long-sentence",
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"location": "body_markdown",
|
||||||
|
"message": f"Sentence exceeds {max_sentence_words} words.",
|
||||||
|
"suggested_fix": "Split or shorten long sentence.",
|
||||||
|
"suggested_rewrite": rewrite,
|
||||||
|
"patch": {
|
||||||
|
"field": "body_replace",
|
||||||
|
"target": first_long_sentence,
|
||||||
|
"value": rewrite,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
forbidden_phrases = rules.get("forbidden_phrases", [])
|
||||||
|
if isinstance(forbidden_phrases, list):
|
||||||
|
lower_body = body_markdown.lower()
|
||||||
|
for phrase in [str(item) for item in forbidden_phrases]:
|
||||||
|
if phrase.lower() in lower_body:
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"id": f"forbidden-{_slugify(phrase)}",
|
||||||
|
"severity": "HIGH",
|
||||||
|
"location": "body_markdown",
|
||||||
|
"message": f"Forbidden phrase found: '{phrase}'.",
|
||||||
|
"suggested_fix": "Replace phrase with neutral wording.",
|
||||||
|
"suggested_rewrite": "Replace with measured, evidence-based language.",
|
||||||
|
"patch": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
break
|
||||||
|
tone = str(rules.get("brand_tone", "neutral"))
|
||||||
|
if tone and tone.lower() in {"strict", "formal"} and "!" in body_markdown:
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"id": "tone-exclamation",
|
||||||
|
"severity": "LOW",
|
||||||
|
"location": "body_markdown",
|
||||||
|
"message": "Exclamation marks conflict with strict/formal tone.",
|
||||||
|
"suggested_fix": "Use neutral punctuation.",
|
||||||
|
"suggested_rewrite": None,
|
||||||
|
"patch": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
issues.sort(key=lambda item: str(item["id"]))
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_report_suggestions(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
report_id: UUID,
|
||||||
|
review_kind: ContentReviewKind,
|
||||||
|
issues: list[dict[str, object]],
|
||||||
|
timestamp: datetime,
|
||||||
|
) -> None:
|
||||||
|
for issue in issues:
|
||||||
|
suggestion_id = _deterministic_suggestion_id(
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=review_kind,
|
||||||
|
suggestion_key=str(issue["id"]),
|
||||||
|
)
|
||||||
|
repository.content_reviews.upsert_suggestion(
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=review_kind,
|
||||||
|
report_id=report_id,
|
||||||
|
suggestion_key=str(issue["id"]),
|
||||||
|
severity=str(issue["severity"]),
|
||||||
|
location=str(issue["location"]),
|
||||||
|
message=str(issue["message"]),
|
||||||
|
suggested_fix=_optional_string(issue.get("suggested_fix")),
|
||||||
|
suggested_rewrite=_optional_string(issue.get("suggested_rewrite")),
|
||||||
|
patch=issue.get("patch", {}) if isinstance(issue.get("patch"), dict) else {},
|
||||||
|
created_at=timestamp,
|
||||||
|
updated_at=timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _deterministic_suggestion_id(
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
review_kind: ContentReviewKind,
|
||||||
|
suggestion_key: str,
|
||||||
|
) -> UUID:
|
||||||
|
return uuid5(
|
||||||
|
NAMESPACE_URL,
|
||||||
|
f"{article_id}:{review_kind.value}:{suggestion_key}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_suggestion_to_draft(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
suggestion: ContentReviewSuggestionSummary,
|
||||||
|
override_text: str | None,
|
||||||
|
) -> DraftSummary | None:
|
||||||
|
request = _build_draft_update_request(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
suggestion=suggestion,
|
||||||
|
override_text=override_text,
|
||||||
|
)
|
||||||
|
if request is None:
|
||||||
|
return None
|
||||||
|
base = _require_latest_draft(repository, article_id=article_id)
|
||||||
|
return update_draft(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
draft_id=base.id,
|
||||||
|
request=request,
|
||||||
|
).draft
|
||||||
|
|
||||||
|
|
||||||
|
def _build_draft_update_request(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
suggestion: ContentReviewSuggestionSummary,
|
||||||
|
override_text: str | None,
|
||||||
|
) -> DraftUpdateRequest | None:
|
||||||
|
patch = suggestion.patch
|
||||||
|
if not isinstance(patch, dict) or not patch:
|
||||||
|
return None
|
||||||
|
base = _require_latest_draft(repository, article_id=article_id)
|
||||||
|
field = _optional_string(patch.get("field"))
|
||||||
|
if field == "meta_title":
|
||||||
|
value = override_text or _optional_string(patch.get("value"))
|
||||||
|
if value and value != (base.meta_title or ""):
|
||||||
|
return DraftUpdateRequest(meta_title=value)
|
||||||
|
return None
|
||||||
|
if field == "meta_description":
|
||||||
|
value = override_text or _optional_string(patch.get("value"))
|
||||||
|
if value and value != (base.meta_description or ""):
|
||||||
|
return DraftUpdateRequest(meta_description=value)
|
||||||
|
return None
|
||||||
|
if field == "title":
|
||||||
|
value = override_text or _optional_string(patch.get("value"))
|
||||||
|
if value and value != base.title:
|
||||||
|
return DraftUpdateRequest(title=value)
|
||||||
|
return None
|
||||||
|
if field == "body_append":
|
||||||
|
value = override_text or _optional_string(patch.get("value"))
|
||||||
|
if value:
|
||||||
|
return DraftUpdateRequest(body_markdown=f"{base.body_markdown}{value}")
|
||||||
|
return None
|
||||||
|
if field == "body_replace":
|
||||||
|
target = _optional_string(patch.get("target"))
|
||||||
|
value = override_text or _optional_string(patch.get("value"))
|
||||||
|
if target and value and target in base.body_markdown:
|
||||||
|
return DraftUpdateRequest(body_markdown=base.body_markdown.replace(target, value, 1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_review_ready(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
event_type: str,
|
||||||
|
count: int,
|
||||||
|
) -> None:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
now = _now()
|
||||||
|
if article.status != ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY:
|
||||||
|
repository.articles.update_status(
|
||||||
|
article_id=article_id,
|
||||||
|
status=ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article_id,
|
||||||
|
event_type=event_type,
|
||||||
|
from_status=article.status,
|
||||||
|
to_status=ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
|
||||||
|
actor_user_id=None,
|
||||||
|
payload={"issue_count": count},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_sentence_longer_than(value: str, max_words: int) -> str | None:
|
||||||
|
plain = re.sub(r"\[[^\]]+\]\([^)]+\)", "link", value)
|
||||||
|
plain = re.sub(r"^#+\s+", "", plain, flags=re.MULTILINE)
|
||||||
|
sentences = re.split(r"[.!?]\s+", plain)
|
||||||
|
for sentence in sentences:
|
||||||
|
words = [item for item in sentence.strip().split() if item]
|
||||||
|
if len(words) > max_words:
|
||||||
|
return sentence.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _shorten_sentence(sentence: str, *, max_words: int) -> str:
|
||||||
|
words = [item for item in sentence.split() if item]
|
||||||
|
if len(words) <= max_words:
|
||||||
|
return sentence.strip()
|
||||||
|
return " ".join(words[: max_words - 2] + ["with", "evidence."])
|
||||||
|
|
||||||
|
|
||||||
|
def _trim(value: str, *, min_len: int, max_len: int) -> str:
|
||||||
|
cleaned = " ".join(value.split())
|
||||||
|
if len(cleaned) > max_len:
|
||||||
|
return cleaned[: max_len - 1].rstrip() + "…"
|
||||||
|
if len(cleaned) < min_len:
|
||||||
|
suffix = " SEO guidance"
|
||||||
|
while len(cleaned) < min_len:
|
||||||
|
cleaned = f"{cleaned}{suffix}"
|
||||||
|
return cleaned[:max_len]
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _first_duplicate(values: list[str]) -> str | None:
|
||||||
|
seen: set[str] = set()
|
||||||
|
for value in values:
|
||||||
|
key = value.strip().lower()
|
||||||
|
if key in seen:
|
||||||
|
return value.strip()
|
||||||
|
seen.add(key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _recommended_title(*, title: str, primary_keyword: str) -> str:
|
||||||
|
if primary_keyword and primary_keyword.lower() not in title.lower():
|
||||||
|
return _trim(f"{title}: {primary_keyword} guide", min_len=30, max_len=60)
|
||||||
|
return _trim(title, min_len=30, max_len=60)
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(value: str) -> str:
|
||||||
|
normalized = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||||
|
return normalized or "draft"
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_string(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from hashlib import sha256
|
||||||
|
from uuid import NAMESPACE_URL, UUID, uuid5
|
||||||
|
|
||||||
|
from src.application.demo_runtime import (
|
||||||
|
ensure_demo_bare_repository,
|
||||||
|
is_demo_mode,
|
||||||
|
resolve_demo_repository_path,
|
||||||
|
)
|
||||||
|
from src.domain.contracts import (
|
||||||
|
PublishingRules,
|
||||||
|
Role,
|
||||||
|
ScriptConfigVersionStatus,
|
||||||
|
WorkflowTemplateStatus,
|
||||||
|
)
|
||||||
|
from src.domain.schema import (
|
||||||
|
SEEDED_ADMIN_EMAIL,
|
||||||
|
SEEDED_EDITOR_EMAIL,
|
||||||
|
SEEDED_TARGET_SITE_SLUG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SEED_TIMESTAMP = datetime(2026, 1, 1, tzinfo=UTC)
|
||||||
|
SEEDED_WORKFLOW_TEMPLATE_SLUG = "demo-article-production"
|
||||||
|
|
||||||
|
|
||||||
|
def seed_reference_data(repository: object) -> None:
|
||||||
|
admin_id = _stable_uuid("user:admin")
|
||||||
|
editor_id = _stable_uuid("user:editor")
|
||||||
|
target_site_id = _stable_uuid(f"target-site:{SEEDED_TARGET_SITE_SLUG}")
|
||||||
|
script_config_version_id = _stable_uuid(
|
||||||
|
f"script-config-version:{SEEDED_TARGET_SITE_SLUG}:1"
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.users.upsert(
|
||||||
|
user_id=admin_id,
|
||||||
|
email=SEEDED_ADMIN_EMAIL,
|
||||||
|
display_name="Admin User",
|
||||||
|
role=Role.ADMIN,
|
||||||
|
created_at=SEED_TIMESTAMP,
|
||||||
|
updated_at=SEED_TIMESTAMP,
|
||||||
|
)
|
||||||
|
repository.users.upsert(
|
||||||
|
user_id=editor_id,
|
||||||
|
email=SEEDED_EDITOR_EMAIL,
|
||||||
|
display_name="Editor User",
|
||||||
|
role=Role.EDITOR,
|
||||||
|
created_at=SEED_TIMESTAMP,
|
||||||
|
updated_at=SEED_TIMESTAMP,
|
||||||
|
)
|
||||||
|
|
||||||
|
repository_url = _repository_url_for_seed()
|
||||||
|
publishing_rules = PublishingRules(
|
||||||
|
repository_url=repository_url,
|
||||||
|
production_branch="main",
|
||||||
|
content_format="mdx",
|
||||||
|
content_path_template="content/articles/{slug}.mdx",
|
||||||
|
asset_path_template="public/articles/{slug}/{filename}",
|
||||||
|
frontmatter_mapping={
|
||||||
|
"title": "title",
|
||||||
|
"description": "meta_description",
|
||||||
|
"publishedAt": "published_at",
|
||||||
|
},
|
||||||
|
transform_script_version_id=script_config_version_id,
|
||||||
|
dry_run_renderer="next-mdx",
|
||||||
|
)
|
||||||
|
repository.target_sites.upsert(
|
||||||
|
site_id=target_site_id,
|
||||||
|
name="B2B SaaS Blog",
|
||||||
|
slug=SEEDED_TARGET_SITE_SLUG,
|
||||||
|
publishing_type="git_next",
|
||||||
|
default_language="en",
|
||||||
|
brand_voice="Clear, practical, evidence-led B2B editorial voice.",
|
||||||
|
audience="B2B SaaS founders and growth teams.",
|
||||||
|
seo_rules={
|
||||||
|
"title_max_length": 60,
|
||||||
|
"meta_description_max_length": 155,
|
||||||
|
"primary_keyword_required": True,
|
||||||
|
},
|
||||||
|
visual_rules={
|
||||||
|
"hero_style": "product editorial",
|
||||||
|
"diagram_format": "mermaid",
|
||||||
|
},
|
||||||
|
source_rules={
|
||||||
|
"minimum_sources": 3,
|
||||||
|
"preferred_source_types": ["primary", "analyst", "vendor_docs"],
|
||||||
|
},
|
||||||
|
publishing_rules=publishing_rules,
|
||||||
|
active_script_config_version_id=script_config_version_id,
|
||||||
|
created_at=SEED_TIMESTAMP,
|
||||||
|
updated_at=SEED_TIMESTAMP,
|
||||||
|
)
|
||||||
|
|
||||||
|
publishing_yaml = f"""target: b2b_saas_blog
|
||||||
|
repository:
|
||||||
|
url: {repository_url}
|
||||||
|
branch: main
|
||||||
|
content:
|
||||||
|
format: mdx
|
||||||
|
path_template: content/articles/{{slug}}.mdx
|
||||||
|
assets:
|
||||||
|
path_template: public/articles/{{slug}}/{{filename}}
|
||||||
|
"""
|
||||||
|
transform_script = """export function transformArticle(article) {
|
||||||
|
return {
|
||||||
|
frontmatter: article.frontmatter,
|
||||||
|
body: article.body,
|
||||||
|
assets: article.assets,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
repository.script_config_versions.upsert(
|
||||||
|
version_id=script_config_version_id,
|
||||||
|
target_site_id=target_site_id,
|
||||||
|
version=1,
|
||||||
|
status=ScriptConfigVersionStatus.ACTIVE,
|
||||||
|
created_by=admin_id,
|
||||||
|
created_at=SEED_TIMESTAMP,
|
||||||
|
updated_at=SEED_TIMESTAMP,
|
||||||
|
diff={"summary": "Initial Git-backed Next.js publishing configuration."},
|
||||||
|
rollback_target_version_id=None,
|
||||||
|
activated_at=SEED_TIMESTAMP,
|
||||||
|
publishing_yaml=publishing_yaml,
|
||||||
|
publishing_yaml_hash=_sha256(publishing_yaml),
|
||||||
|
transform_script=transform_script,
|
||||||
|
transform_script_hash=_sha256(transform_script),
|
||||||
|
)
|
||||||
|
_seed_demo_workflow_template(repository, admin_id=admin_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_demo_workflow_template(repository: object, *, admin_id: UUID) -> None:
|
||||||
|
existing = next(
|
||||||
|
(
|
||||||
|
workflow
|
||||||
|
for workflow in repository.workflow_templates.list()
|
||||||
|
if workflow.slug == SEEDED_WORKFLOW_TEMPLATE_SLUG
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
workflow = repository.workflow_templates.create(
|
||||||
|
name="Demo article production workflow",
|
||||||
|
slug=SEEDED_WORKFLOW_TEMPLATE_SLUG,
|
||||||
|
description="Active demo workflow for article intake, drafting, review, and publishing.",
|
||||||
|
status=WorkflowTemplateStatus.DRAFT,
|
||||||
|
version=1,
|
||||||
|
created_by=admin_id,
|
||||||
|
updated_by=admin_id,
|
||||||
|
created_at=SEED_TIMESTAMP,
|
||||||
|
updated_at=SEED_TIMESTAMP,
|
||||||
|
)
|
||||||
|
for stage in _demo_workflow_stages():
|
||||||
|
repository.workflow_template_stages.create(
|
||||||
|
workflow_id=workflow.id,
|
||||||
|
stable_key=stage["stable_key"],
|
||||||
|
display_name=stage["display_name"],
|
||||||
|
description=stage["description"],
|
||||||
|
position=stage["position"],
|
||||||
|
owner_role=stage["owner_role"],
|
||||||
|
runner_profile_key=stage["runner_profile_key"],
|
||||||
|
required_inputs=stage["required_inputs"],
|
||||||
|
expected_outputs=stage["expected_outputs"],
|
||||||
|
acceptance_criteria=stage["acceptance_criteria"],
|
||||||
|
requires_human_approval=stage["requires_human_approval"],
|
||||||
|
retry_policy=stage["retry_policy"],
|
||||||
|
parts=stage["parts"],
|
||||||
|
updated_by=admin_id,
|
||||||
|
created_at=SEED_TIMESTAMP,
|
||||||
|
updated_at=SEED_TIMESTAMP,
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.workflow_templates.activate(
|
||||||
|
workflow_id=workflow.id,
|
||||||
|
updated_by=admin_id,
|
||||||
|
activated_at=SEED_TIMESTAMP,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _demo_workflow_stages() -> list[dict[str, object]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"stable_key": "intake-boundary-questions",
|
||||||
|
"display_name": "Boundary question intake",
|
||||||
|
"description": "Collect required editorial context before plan generation.",
|
||||||
|
"position": 1,
|
||||||
|
"owner_role": Role.EDITOR,
|
||||||
|
"runner_profile_key": "boundary-question-agent-v1",
|
||||||
|
"required_inputs": ["article_brief", "target_site"],
|
||||||
|
"expected_outputs": ["answered_boundary_questions"],
|
||||||
|
"acceptance_criteria": ["All required boundary questions are answered."],
|
||||||
|
"requires_human_approval": True,
|
||||||
|
"retry_policy": {"max_attempts": 1, "backoff_seconds": 0},
|
||||||
|
"parts": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stable_key": "plan-review",
|
||||||
|
"display_name": "Plan review",
|
||||||
|
"description": "Generate and approve the article plan before research.",
|
||||||
|
"position": 2,
|
||||||
|
"owner_role": Role.EDITOR,
|
||||||
|
"runner_profile_key": "plan-generation-agent-v1",
|
||||||
|
"required_inputs": ["answered_boundary_questions"],
|
||||||
|
"expected_outputs": ["approved_plan"],
|
||||||
|
"acceptance_criteria": ["Plan is approved by the editor."],
|
||||||
|
"requires_human_approval": True,
|
||||||
|
"retry_policy": {"max_attempts": 2, "backoff_seconds": 30},
|
||||||
|
"parts": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stable_key": "research-evidence",
|
||||||
|
"display_name": "Research and evidence",
|
||||||
|
"description": "Run research and approve evidence before production starts.",
|
||||||
|
"position": 3,
|
||||||
|
"owner_role": Role.EDITOR,
|
||||||
|
"runner_profile_key": "research-agent-v1",
|
||||||
|
"required_inputs": ["approved_plan"],
|
||||||
|
"expected_outputs": ["approved_evidence_matrix"],
|
||||||
|
"acceptance_criteria": ["Evidence matrix is ready and approved."],
|
||||||
|
"requires_human_approval": True,
|
||||||
|
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
|
||||||
|
"parts": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stable_key": "parallel-production",
|
||||||
|
"display_name": "Parallel production",
|
||||||
|
"description": "Create section scaffolds and draft-ready assets in parallel.",
|
||||||
|
"position": 4,
|
||||||
|
"owner_role": Role.EDITOR,
|
||||||
|
"runner_profile_key": "section-scaffold-agent-v1",
|
||||||
|
"required_inputs": ["approved_evidence_matrix"],
|
||||||
|
"expected_outputs": ["section_scaffolds", "asset_specs"],
|
||||||
|
"acceptance_criteria": ["All production jobs have succeeded or been retried."],
|
||||||
|
"requires_human_approval": False,
|
||||||
|
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
|
||||||
|
"parts": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stable_key": "draft-assembly",
|
||||||
|
"display_name": "Draft assembly",
|
||||||
|
"description": "Assemble a draft from the approved plan and production artifacts.",
|
||||||
|
"position": 5,
|
||||||
|
"owner_role": Role.EDITOR,
|
||||||
|
"runner_profile_key": "draft-assembly-agent-v1",
|
||||||
|
"required_inputs": ["section_scaffolds", "approved_assets"],
|
||||||
|
"expected_outputs": ["article_draft"],
|
||||||
|
"acceptance_criteria": ["Draft is assembled for review."],
|
||||||
|
"requires_human_approval": False,
|
||||||
|
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
|
||||||
|
"parts": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stable_key": "seo-language-review",
|
||||||
|
"display_name": "SEO and language review",
|
||||||
|
"description": "Run automated quality reviews and resolve required suggestions.",
|
||||||
|
"position": 6,
|
||||||
|
"owner_role": Role.EDITOR,
|
||||||
|
"runner_profile_key": "review-agent-v1",
|
||||||
|
"required_inputs": ["article_draft"],
|
||||||
|
"expected_outputs": ["resolved_review_suggestions"],
|
||||||
|
"acceptance_criteria": ["Required SEO and language suggestions are resolved."],
|
||||||
|
"requires_human_approval": True,
|
||||||
|
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
|
||||||
|
"parts": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stable_key": "final-publishing",
|
||||||
|
"display_name": "Final approval and publishing",
|
||||||
|
"description": "Approve final draft, dry-run the bundle, and create the publish commit.",
|
||||||
|
"position": 7,
|
||||||
|
"owner_role": Role.EDITOR,
|
||||||
|
"runner_profile_key": "publishing-agent-v1",
|
||||||
|
"required_inputs": ["resolved_review_suggestions", "approved_assets"],
|
||||||
|
"expected_outputs": ["publish_commit"],
|
||||||
|
"acceptance_criteria": ["Publish dry-run passes and commit is created."],
|
||||||
|
"requires_human_approval": True,
|
||||||
|
"retry_policy": {"max_attempts": 1, "backoff_seconds": 0},
|
||||||
|
"parts": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _repository_url_for_seed() -> str:
|
||||||
|
if not is_demo_mode():
|
||||||
|
return "git@github.com:example/site.git"
|
||||||
|
demo_repo_path = resolve_demo_repository_path()
|
||||||
|
ensure_demo_bare_repository(demo_repo_path)
|
||||||
|
return str(demo_repo_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _stable_uuid(value: str) -> UUID:
|
||||||
|
return uuid5(NAMESPACE_URL, f"ai-content-pipeline:{value}")
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(value: str) -> str:
|
||||||
|
return sha256(value.encode("utf-8")).hexdigest()
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from hashlib import sha256
|
||||||
|
from uuid import NAMESPACE_URL, UUID, uuid5
|
||||||
|
|
||||||
|
from src.domain.contracts import (
|
||||||
|
CurrentUser,
|
||||||
|
ScriptConfigVersionAuditEventListResponse,
|
||||||
|
ScriptConfigVersionAuditEventSummary,
|
||||||
|
ScriptConfigVersionCreateRequest,
|
||||||
|
ScriptConfigVersionResponse,
|
||||||
|
ScriptConfigVersionStatus,
|
||||||
|
ScriptConfigVersionSummary,
|
||||||
|
TargetSiteConfigCreateRequest,
|
||||||
|
TargetSiteConfigResponse,
|
||||||
|
TargetSiteConfigUpdateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_VERSION_CREATED_EVENT_TYPE = "SCRIPT_CONFIG_VERSION_CREATED"
|
||||||
|
_VERSION_ACTIVATED_EVENT_TYPE = "SCRIPT_CONFIG_VERSION_ACTIVATED"
|
||||||
|
_VERSION_ROLLBACK_EVENT_TYPE = "SCRIPT_CONFIG_VERSION_ROLLBACK"
|
||||||
|
|
||||||
|
|
||||||
|
def create_target_site(
|
||||||
|
repository: object,
|
||||||
|
request: TargetSiteConfigCreateRequest,
|
||||||
|
) -> TargetSiteConfigResponse:
|
||||||
|
now = _now()
|
||||||
|
site = repository.target_sites.upsert(
|
||||||
|
site_id=_stable_uuid(f"target-site:{request.slug}"),
|
||||||
|
name=request.name,
|
||||||
|
slug=request.slug,
|
||||||
|
publishing_type=request.publishing_type,
|
||||||
|
default_language=request.default_language,
|
||||||
|
brand_voice=request.brand_voice,
|
||||||
|
audience=request.audience,
|
||||||
|
seo_rules=request.seo_rules,
|
||||||
|
visual_rules=request.visual_rules,
|
||||||
|
source_rules=request.source_rules,
|
||||||
|
publishing_rules=request.publishing_rules,
|
||||||
|
active_script_config_version_id=request.active_script_config_version_id,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
return TargetSiteConfigResponse(site=site)
|
||||||
|
|
||||||
|
|
||||||
|
def get_target_site(
|
||||||
|
repository: object,
|
||||||
|
site_id: UUID,
|
||||||
|
) -> TargetSiteConfigResponse:
|
||||||
|
site = repository.target_sites.get_by_id(site_id)
|
||||||
|
return TargetSiteConfigResponse(site=site)
|
||||||
|
|
||||||
|
|
||||||
|
def update_target_site(
|
||||||
|
repository: object,
|
||||||
|
site_id: UUID,
|
||||||
|
request: TargetSiteConfigUpdateRequest,
|
||||||
|
) -> TargetSiteConfigResponse:
|
||||||
|
existing = repository.target_sites.get_by_id(site_id)
|
||||||
|
changes = request.model_dump(exclude_unset=True)
|
||||||
|
publishing_rules = (
|
||||||
|
request.publishing_rules
|
||||||
|
if "publishing_rules" in changes
|
||||||
|
else existing.publishing_rules
|
||||||
|
)
|
||||||
|
now = _now()
|
||||||
|
site = repository.target_sites.update(
|
||||||
|
site_id=existing.id,
|
||||||
|
name=changes.get("name", existing.name),
|
||||||
|
slug=changes.get("slug", existing.slug),
|
||||||
|
publishing_type=changes.get("publishing_type", existing.publishing_type),
|
||||||
|
default_language=changes.get("default_language", existing.default_language),
|
||||||
|
brand_voice=changes.get("brand_voice", existing.brand_voice),
|
||||||
|
audience=changes.get("audience", existing.audience),
|
||||||
|
seo_rules=changes.get("seo_rules", existing.seo_rules),
|
||||||
|
visual_rules=changes.get("visual_rules", existing.visual_rules),
|
||||||
|
source_rules=changes.get("source_rules", existing.source_rules),
|
||||||
|
publishing_rules=publishing_rules,
|
||||||
|
active_script_config_version_id=changes.get(
|
||||||
|
"active_script_config_version_id",
|
||||||
|
existing.active_script_config_version_id,
|
||||||
|
),
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
return TargetSiteConfigResponse(site=site)
|
||||||
|
|
||||||
|
|
||||||
|
def create_script_config_version(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
site_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
request: ScriptConfigVersionCreateRequest,
|
||||||
|
) -> ScriptConfigVersionResponse:
|
||||||
|
repository.target_sites.get_by_id(site_id)
|
||||||
|
|
||||||
|
if request.rollback_target_version_id is not None:
|
||||||
|
rollback_target = repository.script_config_versions.get_by_id(
|
||||||
|
request.rollback_target_version_id
|
||||||
|
)
|
||||||
|
if str(rollback_target["target_site_id"]) != str(site_id):
|
||||||
|
raise LookupError(
|
||||||
|
f"Rollback target version is not for site: {request.rollback_target_version_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_versions = repository.script_config_versions.list_for_site(site_id)
|
||||||
|
version = request.version or _next_version(existing_versions)
|
||||||
|
now = _now()
|
||||||
|
version_id = _stable_uuid(f"script-config-version:{site_id}:{version}")
|
||||||
|
status = (
|
||||||
|
ScriptConfigVersionStatus.ACTIVE
|
||||||
|
if request.activate
|
||||||
|
else ScriptConfigVersionStatus.DRAFT
|
||||||
|
)
|
||||||
|
row = repository.script_config_versions.upsert(
|
||||||
|
version_id=version_id,
|
||||||
|
target_site_id=site_id,
|
||||||
|
version=version,
|
||||||
|
status=status,
|
||||||
|
created_by=current_user.id,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
diff=request.diff,
|
||||||
|
rollback_target_version_id=request.rollback_target_version_id,
|
||||||
|
activated_at=now if request.activate else None,
|
||||||
|
publishing_yaml=request.publishing_yaml,
|
||||||
|
publishing_yaml_hash=_sha256(request.publishing_yaml),
|
||||||
|
transform_script=request.transform_script,
|
||||||
|
transform_script_hash=_sha256(request.transform_script),
|
||||||
|
)
|
||||||
|
if request.activate:
|
||||||
|
row = repository.script_config_versions.activate(
|
||||||
|
target_site_id=site_id,
|
||||||
|
version_id=version_id,
|
||||||
|
activated_at=now,
|
||||||
|
rollback_target_version_id=request.rollback_target_version_id,
|
||||||
|
)
|
||||||
|
repository.script_config_version_events.create(
|
||||||
|
target_site_id=site_id,
|
||||||
|
version_id=version_id,
|
||||||
|
event_type=_VERSION_ACTIVATED_EVENT_TYPE,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={"action": "activate", "version": version},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
repository.script_config_version_events.create(
|
||||||
|
target_site_id=site_id,
|
||||||
|
version_id=version_id,
|
||||||
|
event_type=_VERSION_CREATED_EVENT_TYPE,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={"action": "create", "version": version},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ScriptConfigVersionResponse(version=_script_version_summary(row))
|
||||||
|
|
||||||
|
|
||||||
|
def activate_script_config_version(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
site_id: UUID,
|
||||||
|
version_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> ScriptConfigVersionResponse:
|
||||||
|
repository.target_sites.get_by_id(site_id)
|
||||||
|
row = repository.script_config_versions.activate(
|
||||||
|
target_site_id=site_id,
|
||||||
|
version_id=version_id,
|
||||||
|
activated_at=_now(),
|
||||||
|
)
|
||||||
|
repository.script_config_version_events.create(
|
||||||
|
target_site_id=site_id,
|
||||||
|
version_id=version_id,
|
||||||
|
event_type=_VERSION_ACTIVATED_EVENT_TYPE,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={"action": "activate", "version_id": str(version_id)},
|
||||||
|
created_at=_now(),
|
||||||
|
)
|
||||||
|
return ScriptConfigVersionResponse(version=_script_version_summary(row))
|
||||||
|
|
||||||
|
|
||||||
|
def rollback_script_config_version(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
site_id: UUID,
|
||||||
|
version_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> ScriptConfigVersionResponse:
|
||||||
|
target_site = repository.target_sites.get_by_id(site_id)
|
||||||
|
version_row = repository.script_config_versions.get_by_id(version_id)
|
||||||
|
if str(version_row["target_site_id"]) != str(site_id):
|
||||||
|
raise LookupError(
|
||||||
|
f"Script config version not found for site: {site_id} {version_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
rollback_target_id = target_site.active_script_config_version_id
|
||||||
|
if rollback_target_id == version_id:
|
||||||
|
rollback_target_id = None
|
||||||
|
|
||||||
|
row = repository.script_config_versions.activate(
|
||||||
|
target_site_id=site_id,
|
||||||
|
version_id=version_id,
|
||||||
|
activated_at=_now(),
|
||||||
|
rollback_target_version_id=rollback_target_id,
|
||||||
|
)
|
||||||
|
repository.script_config_version_events.create(
|
||||||
|
target_site_id=site_id,
|
||||||
|
version_id=version_id,
|
||||||
|
event_type=_VERSION_ROLLBACK_EVENT_TYPE,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={
|
||||||
|
"action": "rollback",
|
||||||
|
"rollback_target_version_id": (
|
||||||
|
str(rollback_target_id) if rollback_target_id is not None else None
|
||||||
|
),
|
||||||
|
},
|
||||||
|
created_at=_now(),
|
||||||
|
)
|
||||||
|
return ScriptConfigVersionResponse(version=_script_version_summary(row))
|
||||||
|
|
||||||
|
|
||||||
|
def list_script_config_versions(
|
||||||
|
repository: object,
|
||||||
|
site_id: UUID,
|
||||||
|
) -> list[ScriptConfigVersionSummary]:
|
||||||
|
repository.target_sites.get_by_id(site_id)
|
||||||
|
rows = repository.script_config_versions.list_for_site(site_id)
|
||||||
|
return [_script_version_summary(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def list_script_config_audit_events(
|
||||||
|
repository: object,
|
||||||
|
site_id: UUID,
|
||||||
|
) -> ScriptConfigVersionAuditEventListResponse:
|
||||||
|
repository.target_sites.get_by_id(site_id)
|
||||||
|
version_rows = repository.script_config_versions.list_for_site(site_id)
|
||||||
|
version_by_id = {str(row["id"]): row for row in version_rows}
|
||||||
|
events = repository.script_config_version_events.list_for_site(site_id)
|
||||||
|
|
||||||
|
summaries: list[ScriptConfigVersionAuditEventSummary] = []
|
||||||
|
for event in events:
|
||||||
|
version_row = version_by_id.get(str(event["version_id"]))
|
||||||
|
payload = event["payload"] if isinstance(event["payload"], dict) else {}
|
||||||
|
summaries.append(
|
||||||
|
ScriptConfigVersionAuditEventSummary(
|
||||||
|
id=event["id"],
|
||||||
|
target_site_id=event["target_site_id"],
|
||||||
|
version_id=event["version_id"],
|
||||||
|
event_type=event["event_type"],
|
||||||
|
actor_user_id=event["actor_user_id"],
|
||||||
|
payload=payload,
|
||||||
|
created_at=event["created_at"],
|
||||||
|
version=(
|
||||||
|
int(version_row["version"])
|
||||||
|
if version_row is not None and version_row.get("version") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
diff=(
|
||||||
|
version_row["diff"]
|
||||||
|
if version_row is not None and isinstance(version_row.get("diff"), dict)
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
rollback_target_version_id=(
|
||||||
|
version_row["rollback_target_version_id"]
|
||||||
|
if version_row is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ScriptConfigVersionAuditEventListResponse(events=summaries)
|
||||||
|
|
||||||
|
|
||||||
|
def _script_version_summary(row: dict[str, object]) -> ScriptConfigVersionSummary:
|
||||||
|
return ScriptConfigVersionSummary(
|
||||||
|
id=row["id"],
|
||||||
|
target_site_id=row["target_site_id"],
|
||||||
|
version=row["version"],
|
||||||
|
status=row["status"],
|
||||||
|
diff=row["diff"],
|
||||||
|
rollback_target_version_id=row["rollback_target_version_id"],
|
||||||
|
publishing_yaml_hash=row["publishing_yaml_hash"],
|
||||||
|
transform_script_hash=row["transform_script_hash"],
|
||||||
|
created_by=row["created_by"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
activated_at=row["activated_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_version(rows: list[dict[str, object]]) -> int:
|
||||||
|
versions = [int(row["version"]) for row in rows]
|
||||||
|
if not versions:
|
||||||
|
return 1
|
||||||
|
return max(versions) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _stable_uuid(value: str) -> UUID:
|
||||||
|
return uuid5(NAMESPACE_URL, f"ai-content-pipeline:{value}")
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(value: str) -> str:
|
||||||
|
return sha256(value.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from src.domain.contracts import (
|
||||||
|
CurrentUser,
|
||||||
|
Role,
|
||||||
|
WorkflowStageCreateRequest,
|
||||||
|
WorkflowStageResponse,
|
||||||
|
WorkflowStageUpdateRequest,
|
||||||
|
WorkflowTemplateAuditEventListResponse,
|
||||||
|
WorkflowTemplateCreateRequest,
|
||||||
|
WorkflowTemplateListResponse,
|
||||||
|
WorkflowTemplateResponse,
|
||||||
|
WorkflowTemplateStatus,
|
||||||
|
WorkflowTemplateSummary,
|
||||||
|
WorkflowTemplateUpdateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_TEMPLATE_CREATED_EVENT_TYPE = "WORKFLOW_TEMPLATE_CREATED"
|
||||||
|
_TEMPLATE_UPDATED_EVENT_TYPE = "WORKFLOW_TEMPLATE_UPDATED"
|
||||||
|
_STAGE_CREATED_EVENT_TYPE = "WORKFLOW_STAGE_CREATED"
|
||||||
|
_STAGE_UPDATED_EVENT_TYPE = "WORKFLOW_STAGE_UPDATED"
|
||||||
|
_STAGE_DELETED_EVENT_TYPE = "WORKFLOW_STAGE_DELETED"
|
||||||
|
_STAGES_REORDERED_EVENT_TYPE = "WORKFLOW_STAGES_REORDERED"
|
||||||
|
_TEMPLATE_ACTIVATED_EVENT_TYPE = "WORKFLOW_TEMPLATE_ACTIVATED"
|
||||||
|
_TEMPLATE_ARCHIVED_EVENT_TYPE = "WORKFLOW_TEMPLATE_ARCHIVED"
|
||||||
|
|
||||||
|
|
||||||
|
def create_workflow_template(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
request: WorkflowTemplateCreateRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
now = _now()
|
||||||
|
workflow = repository.workflow_templates.create(
|
||||||
|
name=request.name,
|
||||||
|
slug=request.slug,
|
||||||
|
description=request.description,
|
||||||
|
status=WorkflowTemplateStatus.DRAFT,
|
||||||
|
version=1,
|
||||||
|
created_by=current_user.id,
|
||||||
|
updated_by=current_user.id,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
_create_event(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow.id,
|
||||||
|
event_type=_TEMPLATE_CREATED_EVENT_TYPE,
|
||||||
|
current_user=current_user,
|
||||||
|
payload={"slug": workflow.slug, "version": workflow.version},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return WorkflowTemplateResponse(workflow=workflow)
|
||||||
|
|
||||||
|
|
||||||
|
def list_workflow_templates(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> WorkflowTemplateListResponse:
|
||||||
|
if current_user.role == Role.ADMIN:
|
||||||
|
workflows = repository.workflow_templates.list()
|
||||||
|
else:
|
||||||
|
workflows = repository.workflow_templates.list_active()
|
||||||
|
return WorkflowTemplateListResponse(workflows=workflows)
|
||||||
|
|
||||||
|
|
||||||
|
def get_workflow_template(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
workflow = repository.workflow_templates.get(workflow_id)
|
||||||
|
if current_user.role != Role.ADMIN and workflow.status != WorkflowTemplateStatus.ACTIVE:
|
||||||
|
raise LookupError(f"Workflow template not found: {workflow_id}")
|
||||||
|
return WorkflowTemplateResponse(workflow=workflow)
|
||||||
|
|
||||||
|
|
||||||
|
def update_workflow_template(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
request: WorkflowTemplateUpdateRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
existing = repository.workflow_templates.get(workflow_id)
|
||||||
|
_require_draft(existing)
|
||||||
|
|
||||||
|
changes = request.model_dump(exclude_unset=True)
|
||||||
|
now = _now()
|
||||||
|
workflow = repository.workflow_templates.update(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
name=changes.get("name", existing.name),
|
||||||
|
slug=changes.get("slug", existing.slug),
|
||||||
|
description=changes.get("description", existing.description),
|
||||||
|
updated_by=current_user.id,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
_create_event(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow.id,
|
||||||
|
event_type=_TEMPLATE_UPDATED_EVENT_TYPE,
|
||||||
|
current_user=current_user,
|
||||||
|
payload={"changed_fields": sorted(changes.keys())},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return WorkflowTemplateResponse(workflow=workflow)
|
||||||
|
|
||||||
|
|
||||||
|
def add_workflow_stage(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
request: WorkflowStageCreateRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> WorkflowStageResponse:
|
||||||
|
workflow = repository.workflow_templates.get(workflow_id)
|
||||||
|
_require_draft(workflow)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
stage = repository.workflow_template_stages.create(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
stable_key=request.stable_key,
|
||||||
|
display_name=request.display_name,
|
||||||
|
description=request.description,
|
||||||
|
position=request.position,
|
||||||
|
owner_role=request.owner_role,
|
||||||
|
runner_profile_key=request.runner_profile_key,
|
||||||
|
required_inputs=request.required_inputs,
|
||||||
|
expected_outputs=request.expected_outputs,
|
||||||
|
acceptance_criteria=request.acceptance_criteria,
|
||||||
|
requires_human_approval=request.requires_human_approval,
|
||||||
|
retry_policy=request.retry_policy,
|
||||||
|
parts=_parts_json(request.parts),
|
||||||
|
updated_by=current_user.id,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
_create_event(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
event_type=_STAGE_CREATED_EVENT_TYPE,
|
||||||
|
current_user=current_user,
|
||||||
|
payload={
|
||||||
|
"stage_id": str(stage.id),
|
||||||
|
"stable_key": stage.stable_key,
|
||||||
|
"position": stage.position,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return WorkflowStageResponse(stage=stage)
|
||||||
|
|
||||||
|
|
||||||
|
def update_workflow_stage(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
stage_id: UUID,
|
||||||
|
request: WorkflowStageUpdateRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> WorkflowStageResponse:
|
||||||
|
workflow = repository.workflow_templates.get(workflow_id)
|
||||||
|
_require_draft(workflow)
|
||||||
|
existing = repository.workflow_template_stages.get(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
stage_id=stage_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
changes = request.model_dump(exclude_unset=True)
|
||||||
|
now = _now()
|
||||||
|
stage = repository.workflow_template_stages.update(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
stage_id=stage_id,
|
||||||
|
stable_key=changes.get("stable_key", existing.stable_key),
|
||||||
|
display_name=changes.get("display_name", existing.display_name),
|
||||||
|
description=changes.get("description", existing.description),
|
||||||
|
position=changes.get("position", existing.position),
|
||||||
|
owner_role=request.owner_role or existing.owner_role,
|
||||||
|
runner_profile_key=changes.get(
|
||||||
|
"runner_profile_key",
|
||||||
|
existing.runner_profile_key,
|
||||||
|
),
|
||||||
|
required_inputs=changes.get("required_inputs", existing.required_inputs),
|
||||||
|
expected_outputs=changes.get("expected_outputs", existing.expected_outputs),
|
||||||
|
acceptance_criteria=changes.get(
|
||||||
|
"acceptance_criteria",
|
||||||
|
existing.acceptance_criteria,
|
||||||
|
),
|
||||||
|
requires_human_approval=changes.get(
|
||||||
|
"requires_human_approval",
|
||||||
|
existing.requires_human_approval,
|
||||||
|
),
|
||||||
|
retry_policy=changes.get("retry_policy", existing.retry_policy),
|
||||||
|
parts=(
|
||||||
|
_parts_json(request.parts)
|
||||||
|
if request.parts is not None
|
||||||
|
else _parts_json(existing.parts)
|
||||||
|
),
|
||||||
|
updated_by=current_user.id,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
_create_event(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
event_type=_STAGE_UPDATED_EVENT_TYPE,
|
||||||
|
current_user=current_user,
|
||||||
|
payload={"stage_id": str(stage_id), "changed_fields": sorted(changes.keys())},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return WorkflowStageResponse(stage=stage)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_workflow_stage(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
stage_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
workflow = repository.workflow_templates.get(workflow_id)
|
||||||
|
_require_draft(workflow)
|
||||||
|
existing = repository.workflow_template_stages.get(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
stage_id=stage_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
repository.workflow_template_stages.delete(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
stage_id=stage_id,
|
||||||
|
updated_by=current_user.id,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
_create_event(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
event_type=_STAGE_DELETED_EVENT_TYPE,
|
||||||
|
current_user=current_user,
|
||||||
|
payload={"stage_id": str(stage_id), "stable_key": existing.stable_key},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return WorkflowTemplateResponse(workflow=repository.workflow_templates.get(workflow_id))
|
||||||
|
|
||||||
|
|
||||||
|
def reorder_workflow_stages(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
stage_ids: list[UUID],
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
workflow = repository.workflow_templates.get(workflow_id)
|
||||||
|
_require_draft(workflow)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
repository.workflow_template_stages.reorder(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
stage_ids=stage_ids,
|
||||||
|
updated_by=current_user.id,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
_create_event(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
event_type=_STAGES_REORDERED_EVENT_TYPE,
|
||||||
|
current_user=current_user,
|
||||||
|
payload={"stage_ids": [str(stage_id) for stage_id in stage_ids]},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return WorkflowTemplateResponse(workflow=repository.workflow_templates.get(workflow_id))
|
||||||
|
|
||||||
|
|
||||||
|
def activate_workflow_template(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
workflow = repository.workflow_templates.get(workflow_id)
|
||||||
|
_require_draft(workflow)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
activated = repository.workflow_templates.activate(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
updated_by=current_user.id,
|
||||||
|
activated_at=now,
|
||||||
|
)
|
||||||
|
_create_event(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
event_type=_TEMPLATE_ACTIVATED_EVENT_TYPE,
|
||||||
|
current_user=current_user,
|
||||||
|
payload={"version": activated.version},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return WorkflowTemplateResponse(workflow=activated)
|
||||||
|
|
||||||
|
|
||||||
|
def archive_workflow_template(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
repository.workflow_templates.get(workflow_id)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
archived = repository.workflow_templates.archive(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
updated_by=current_user.id,
|
||||||
|
archived_at=now,
|
||||||
|
)
|
||||||
|
_create_event(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
event_type=_TEMPLATE_ARCHIVED_EVENT_TYPE,
|
||||||
|
current_user=current_user,
|
||||||
|
payload={"version": archived.version},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return WorkflowTemplateResponse(workflow=archived)
|
||||||
|
|
||||||
|
|
||||||
|
def list_workflow_template_audit_events(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
) -> WorkflowTemplateAuditEventListResponse:
|
||||||
|
repository.workflow_templates.get(workflow_id)
|
||||||
|
return WorkflowTemplateAuditEventListResponse(
|
||||||
|
events=repository.workflow_template_events.list_for_workflow(workflow_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_event(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
workflow_id: UUID,
|
||||||
|
event_type: str,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
payload: dict[str, object],
|
||||||
|
created_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
repository.workflow_template_events.create(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
event_type=event_type,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload=payload,
|
||||||
|
created_at=created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_draft(workflow: WorkflowTemplateSummary) -> None:
|
||||||
|
if workflow.status != WorkflowTemplateStatus.DRAFT:
|
||||||
|
raise ValueError("Only draft workflow templates can be mutated.")
|
||||||
|
|
||||||
|
|
||||||
|
def _parts_json(parts: list[object]) -> list[dict[str, object]]:
|
||||||
|
return [
|
||||||
|
part.model_dump(mode="json") if hasattr(part, "model_dump") else dict(part)
|
||||||
|
for part in parts
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.domain.contracts.enums import Role
|
||||||
|
|
||||||
|
|
||||||
|
ADMIN_ROLES = frozenset({Role.ADMIN})
|
||||||
|
EDITOR_OR_ADMIN_ROLES = frozenset({Role.ADMIN, Role.EDITOR})
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
from .enums import (
|
||||||
|
AgentJobErrorCategory,
|
||||||
|
AgentJobStatus,
|
||||||
|
AgentJobType,
|
||||||
|
ArticleWorkflowStatus,
|
||||||
|
ContentReviewKind,
|
||||||
|
AssetStatus,
|
||||||
|
AssetType,
|
||||||
|
ClaimRiskLevel,
|
||||||
|
ClaimSupportStatus,
|
||||||
|
PlanReviewStatus,
|
||||||
|
PublishingStatus,
|
||||||
|
ReviewStatus,
|
||||||
|
ReviewSuggestionStatus,
|
||||||
|
ReviewType,
|
||||||
|
Role,
|
||||||
|
ScriptConfigVersionStatus,
|
||||||
|
WorkflowTemplateStatus,
|
||||||
|
)
|
||||||
|
from .models import (
|
||||||
|
AgentJobOutput,
|
||||||
|
AgentJobListResponse,
|
||||||
|
AgentJobResponse,
|
||||||
|
AgentJobSummary,
|
||||||
|
AgentJobTestCodexRequest,
|
||||||
|
ArticleCreateRequest,
|
||||||
|
ArticleCreateResponse,
|
||||||
|
ArticleDetailResponse,
|
||||||
|
ArticleListResponse,
|
||||||
|
ArticleSummary,
|
||||||
|
BoundaryQuestionListResponse,
|
||||||
|
BoundaryQuestionResponse,
|
||||||
|
BoundaryQuestionSummary,
|
||||||
|
BoundaryQuestionUpdateRequest,
|
||||||
|
WorkflowEventSummary,
|
||||||
|
ObservabilityTimelineEventSummary,
|
||||||
|
WorkflowStageCreateRequest,
|
||||||
|
WorkflowStagePart,
|
||||||
|
WorkflowStageReorderRequest,
|
||||||
|
WorkflowStageResponse,
|
||||||
|
WorkflowStageSummary,
|
||||||
|
WorkflowStageUpdateRequest,
|
||||||
|
WorkflowTemplateAuditEventListResponse,
|
||||||
|
WorkflowTemplateAuditEventSummary,
|
||||||
|
WorkflowTemplateCreateRequest,
|
||||||
|
WorkflowTemplateListResponse,
|
||||||
|
WorkflowTemplateResponse,
|
||||||
|
WorkflowTemplateSnapshot,
|
||||||
|
WorkflowTemplateStageSnapshot,
|
||||||
|
WorkflowTemplateSummary,
|
||||||
|
WorkflowTemplateUpdateRequest,
|
||||||
|
AssetGenerateSpecsResponse,
|
||||||
|
AssetListResponse,
|
||||||
|
AssetResponse,
|
||||||
|
AssetRevisionSummary,
|
||||||
|
AssetSummary,
|
||||||
|
AssetUpdateRequest,
|
||||||
|
AssetUploadRequest,
|
||||||
|
AssetUploadResponse,
|
||||||
|
ClaimSummary,
|
||||||
|
CurrentUser,
|
||||||
|
CurrentUserResponse,
|
||||||
|
DraftAssembleResponse,
|
||||||
|
DraftFaqItem,
|
||||||
|
DraftListResponse,
|
||||||
|
DraftResponse,
|
||||||
|
DraftSummary,
|
||||||
|
DraftUpdateRequest,
|
||||||
|
ContentReviewIssueSummary,
|
||||||
|
ContentReviewReportSummary,
|
||||||
|
ContentReviewSuggestionSummary,
|
||||||
|
EvidenceSummary,
|
||||||
|
EvidenceCreateRequest,
|
||||||
|
EvidenceMatrixResponse,
|
||||||
|
EvidenceResponse,
|
||||||
|
EvidenceUpdateRequest,
|
||||||
|
FinalApprovalChecklistItemSummary,
|
||||||
|
FinalApprovalChecklistResponse,
|
||||||
|
FinalApprovalPublishingSettings,
|
||||||
|
FinalApprovalRequest,
|
||||||
|
FinalApprovalResponse,
|
||||||
|
PlanSectionSummary,
|
||||||
|
PlanSectionEditRequest,
|
||||||
|
PlanListResponse,
|
||||||
|
PlanResponse,
|
||||||
|
PlanRevisionRequest,
|
||||||
|
PlanSummary,
|
||||||
|
PlanUpdateRequest,
|
||||||
|
PublishCommitSummary,
|
||||||
|
PublishCommitCreateResponse,
|
||||||
|
PublishCommitListResponse,
|
||||||
|
PublishingDryRunResponse,
|
||||||
|
PublishingStatusResponse,
|
||||||
|
PublishingRules,
|
||||||
|
ResearchArtifactManifestSummary,
|
||||||
|
ResearchArtifactSummary,
|
||||||
|
ResearchListResponse,
|
||||||
|
ResearchStartResponse,
|
||||||
|
ReviewActionResponse,
|
||||||
|
ReviewSuggestionActionResponse,
|
||||||
|
ReviewSuggestionEditRequest,
|
||||||
|
ReviewSummary,
|
||||||
|
SeoReviewRunResponse,
|
||||||
|
SeoReviewReportResponse,
|
||||||
|
LanguageReviewRunResponse,
|
||||||
|
LanguageReviewReportResponse,
|
||||||
|
FinalRevisionRequest,
|
||||||
|
FinalRevisionRequestResponse,
|
||||||
|
FinalReviewIssueSummary,
|
||||||
|
FinalReviewIssuesResponse,
|
||||||
|
RunnerFileRef,
|
||||||
|
ScriptConfigVersionAuditEventListResponse,
|
||||||
|
ScriptConfigVersionAuditEventSummary,
|
||||||
|
ScriptConfigVersionCreateRequest,
|
||||||
|
ScriptConfigVersionListResponse,
|
||||||
|
ScriptConfigVersionResponse,
|
||||||
|
ScriptConfigVersionSummary,
|
||||||
|
TargetSiteConfig,
|
||||||
|
TargetSiteConfigCreateRequest,
|
||||||
|
TargetSiteConfigResponse,
|
||||||
|
TargetSiteConfigUpdateRequest,
|
||||||
|
UserSummary,
|
||||||
|
)
|
||||||
|
from .workflow import (
|
||||||
|
ARTICLE_WORKFLOW_STATUSES,
|
||||||
|
ARTICLE_WORKFLOW_TRANSITIONS,
|
||||||
|
next_workflow_statuses,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ARTICLE_WORKFLOW_STATUSES",
|
||||||
|
"ARTICLE_WORKFLOW_TRANSITIONS",
|
||||||
|
"AgentJobErrorCategory",
|
||||||
|
"AgentJobListResponse",
|
||||||
|
"AgentJobOutput",
|
||||||
|
"AgentJobResponse",
|
||||||
|
"AgentJobStatus",
|
||||||
|
"AgentJobSummary",
|
||||||
|
"AgentJobTestCodexRequest",
|
||||||
|
"AgentJobType",
|
||||||
|
"ArticleCreateRequest",
|
||||||
|
"ArticleCreateResponse",
|
||||||
|
"ArticleDetailResponse",
|
||||||
|
"ArticleListResponse",
|
||||||
|
"ArticleSummary",
|
||||||
|
"BoundaryQuestionListResponse",
|
||||||
|
"BoundaryQuestionResponse",
|
||||||
|
"BoundaryQuestionSummary",
|
||||||
|
"BoundaryQuestionUpdateRequest",
|
||||||
|
"WorkflowEventSummary",
|
||||||
|
"ObservabilityTimelineEventSummary",
|
||||||
|
"WorkflowStageCreateRequest",
|
||||||
|
"WorkflowStagePart",
|
||||||
|
"WorkflowStageReorderRequest",
|
||||||
|
"WorkflowStageResponse",
|
||||||
|
"WorkflowStageSummary",
|
||||||
|
"WorkflowStageUpdateRequest",
|
||||||
|
"WorkflowTemplateAuditEventListResponse",
|
||||||
|
"WorkflowTemplateAuditEventSummary",
|
||||||
|
"WorkflowTemplateCreateRequest",
|
||||||
|
"WorkflowTemplateListResponse",
|
||||||
|
"WorkflowTemplateResponse",
|
||||||
|
"WorkflowTemplateSnapshot",
|
||||||
|
"WorkflowTemplateStageSnapshot",
|
||||||
|
"WorkflowTemplateStatus",
|
||||||
|
"WorkflowTemplateSummary",
|
||||||
|
"WorkflowTemplateUpdateRequest",
|
||||||
|
"AssetGenerateSpecsResponse",
|
||||||
|
"AssetListResponse",
|
||||||
|
"AssetResponse",
|
||||||
|
"AssetRevisionSummary",
|
||||||
|
"ArticleWorkflowStatus",
|
||||||
|
"AssetStatus",
|
||||||
|
"AssetSummary",
|
||||||
|
"AssetType",
|
||||||
|
"AssetUpdateRequest",
|
||||||
|
"AssetUploadRequest",
|
||||||
|
"AssetUploadResponse",
|
||||||
|
"ClaimRiskLevel",
|
||||||
|
"ClaimSummary",
|
||||||
|
"ClaimSupportStatus",
|
||||||
|
"CurrentUser",
|
||||||
|
"CurrentUserResponse",
|
||||||
|
"ContentReviewIssueSummary",
|
||||||
|
"ContentReviewKind",
|
||||||
|
"ContentReviewReportSummary",
|
||||||
|
"ContentReviewSuggestionSummary",
|
||||||
|
"DraftAssembleResponse",
|
||||||
|
"DraftFaqItem",
|
||||||
|
"DraftListResponse",
|
||||||
|
"DraftResponse",
|
||||||
|
"DraftSummary",
|
||||||
|
"DraftUpdateRequest",
|
||||||
|
"EvidenceSummary",
|
||||||
|
"EvidenceCreateRequest",
|
||||||
|
"EvidenceMatrixResponse",
|
||||||
|
"EvidenceResponse",
|
||||||
|
"EvidenceUpdateRequest",
|
||||||
|
"FinalApprovalChecklistItemSummary",
|
||||||
|
"FinalApprovalChecklistResponse",
|
||||||
|
"FinalApprovalPublishingSettings",
|
||||||
|
"FinalApprovalRequest",
|
||||||
|
"FinalApprovalResponse",
|
||||||
|
"FinalRevisionRequest",
|
||||||
|
"FinalRevisionRequestResponse",
|
||||||
|
"FinalReviewIssueSummary",
|
||||||
|
"FinalReviewIssuesResponse",
|
||||||
|
"LanguageReviewRunResponse",
|
||||||
|
"LanguageReviewReportResponse",
|
||||||
|
"PlanReviewStatus",
|
||||||
|
"PlanListResponse",
|
||||||
|
"PlanResponse",
|
||||||
|
"PlanRevisionRequest",
|
||||||
|
"PlanSectionEditRequest",
|
||||||
|
"PlanSectionSummary",
|
||||||
|
"PlanSummary",
|
||||||
|
"PlanUpdateRequest",
|
||||||
|
"PublishCommitCreateResponse",
|
||||||
|
"PublishCommitListResponse",
|
||||||
|
"PublishCommitSummary",
|
||||||
|
"PublishingDryRunResponse",
|
||||||
|
"PublishingRules",
|
||||||
|
"PublishingStatusResponse",
|
||||||
|
"PublishingStatus",
|
||||||
|
"ResearchArtifactManifestSummary",
|
||||||
|
"ResearchArtifactSummary",
|
||||||
|
"ResearchListResponse",
|
||||||
|
"ResearchStartResponse",
|
||||||
|
"ReviewActionResponse",
|
||||||
|
"ReviewStatus",
|
||||||
|
"ReviewSuggestionActionResponse",
|
||||||
|
"ReviewSuggestionEditRequest",
|
||||||
|
"ReviewSuggestionStatus",
|
||||||
|
"ReviewSummary",
|
||||||
|
"ReviewType",
|
||||||
|
"Role",
|
||||||
|
"RunnerFileRef",
|
||||||
|
"ScriptConfigVersionAuditEventListResponse",
|
||||||
|
"ScriptConfigVersionAuditEventSummary",
|
||||||
|
"SeoReviewRunResponse",
|
||||||
|
"SeoReviewReportResponse",
|
||||||
|
"ScriptConfigVersionCreateRequest",
|
||||||
|
"ScriptConfigVersionListResponse",
|
||||||
|
"ScriptConfigVersionResponse",
|
||||||
|
"ScriptConfigVersionStatus",
|
||||||
|
"ScriptConfigVersionSummary",
|
||||||
|
"TargetSiteConfig",
|
||||||
|
"TargetSiteConfigCreateRequest",
|
||||||
|
"TargetSiteConfigResponse",
|
||||||
|
"TargetSiteConfigUpdateRequest",
|
||||||
|
"UserSummary",
|
||||||
|
"next_workflow_statuses",
|
||||||
|
]
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class Role(str, Enum):
|
||||||
|
ADMIN = "ADMIN"
|
||||||
|
EDITOR = "EDITOR"
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleWorkflowStatus(str, Enum):
|
||||||
|
ARTICLE_BRIEF_CREATED = "ARTICLE_BRIEF_CREATED"
|
||||||
|
BOUNDARY_QUESTIONS_GENERATED = "BOUNDARY_QUESTIONS_GENERATED"
|
||||||
|
BOUNDARY_ANSWERS_SUBMITTED = "BOUNDARY_ANSWERS_SUBMITTED"
|
||||||
|
PLAN_GENERATED = "PLAN_GENERATED"
|
||||||
|
PLAN_REVIEW_REQUIRED = "PLAN_REVIEW_REQUIRED"
|
||||||
|
PLAN_REVISION_REQUIRED = "PLAN_REVISION_REQUIRED"
|
||||||
|
RESEARCH_RUNNING = "RESEARCH_RUNNING"
|
||||||
|
EVIDENCE_MATRIX_READY = "EVIDENCE_MATRIX_READY"
|
||||||
|
PARALLEL_PRODUCTION_RUNNING = "PARALLEL_PRODUCTION_RUNNING"
|
||||||
|
DRAFT_ASSEMBLED = "DRAFT_ASSEMBLED"
|
||||||
|
SEO_AND_LANGUAGE_REVIEW_READY = "SEO_AND_LANGUAGE_REVIEW_READY"
|
||||||
|
FINAL_REVIEW_REQUIRED = "FINAL_REVIEW_REQUIRED"
|
||||||
|
FINAL_REVISION_REQUIRED = "FINAL_REVISION_REQUIRED"
|
||||||
|
PUBLISH_DRY_RUN_REQUIRED = "PUBLISH_DRY_RUN_REQUIRED"
|
||||||
|
PUBLISH_COMMIT_READY = "PUBLISH_COMMIT_READY"
|
||||||
|
PUBLISH_COMMIT_CREATED = "PUBLISH_COMMIT_CREATED"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentJobStatus(str, Enum):
|
||||||
|
QUEUED = "QUEUED"
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
SUCCEEDED = "SUCCEEDED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentJobType(str, Enum):
|
||||||
|
BOUNDARY_QUESTIONS = "BOUNDARY_QUESTIONS"
|
||||||
|
PLAN_GENERATION = "PLAN_GENERATION"
|
||||||
|
RESEARCH = "RESEARCH"
|
||||||
|
EVIDENCE_MATRIX = "EVIDENCE_MATRIX"
|
||||||
|
SECTION_SCAFFOLD = "SECTION_SCAFFOLD"
|
||||||
|
DRAFT_ASSEMBLY = "DRAFT_ASSEMBLY"
|
||||||
|
SEO_REVIEW = "SEO_REVIEW"
|
||||||
|
LANGUAGE_REVIEW = "LANGUAGE_REVIEW"
|
||||||
|
PUBLISH_DRY_RUN = "PUBLISH_DRY_RUN"
|
||||||
|
PUBLISH_COMMIT = "PUBLISH_COMMIT"
|
||||||
|
TEST_CODEX = "TEST_CODEX"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentJobErrorCategory(str, Enum):
|
||||||
|
FAILED_SCHEMA_VALIDATION = "FAILED_SCHEMA_VALIDATION"
|
||||||
|
CLI_EXIT_CODE_FAILURE = "CLI_EXIT_CODE_FAILURE"
|
||||||
|
TIMEOUT = "TIMEOUT"
|
||||||
|
MISSING_OUTPUT_FILE = "MISSING_OUTPUT_FILE"
|
||||||
|
UNSUPPORTED_CLAIMS_FOUND = "UNSUPPORTED_CLAIMS_FOUND"
|
||||||
|
SOURCE_RETRIEVAL_FAILED = "SOURCE_RETRIEVAL_FAILED"
|
||||||
|
RESEARCH_ARTIFACT_UPLOAD_FAILED = "RESEARCH_ARTIFACT_UPLOAD_FAILED"
|
||||||
|
PUBLISH_DRY_RUN_FAILED = "PUBLISH_DRY_RUN_FAILED"
|
||||||
|
GIT_CHECKOUT_FAILED = "GIT_CHECKOUT_FAILED"
|
||||||
|
GIT_COMMIT_FAILED = "GIT_COMMIT_FAILED"
|
||||||
|
GIT_PUSH_NON_FAST_FORWARD = "GIT_PUSH_NON_FAST_FORWARD"
|
||||||
|
PUBLISH_VERIFICATION_FAILED = "PUBLISH_VERIFICATION_FAILED"
|
||||||
|
|
||||||
|
|
||||||
|
class ClaimSupportStatus(str, Enum):
|
||||||
|
SUPPORTED = "SUPPORTED"
|
||||||
|
UNSUPPORTED = "UNSUPPORTED"
|
||||||
|
NEEDS_REVIEW = "NEEDS_REVIEW"
|
||||||
|
|
||||||
|
|
||||||
|
class ClaimRiskLevel(str, Enum):
|
||||||
|
LOW = "low"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
HIGH = "high"
|
||||||
|
|
||||||
|
|
||||||
|
class PublishingStatus(str, Enum):
|
||||||
|
PUBLISH_NOT_STARTED = "PUBLISH_NOT_STARTED"
|
||||||
|
PUBLISH_DRY_RUN_REQUIRED = "PUBLISH_DRY_RUN_REQUIRED"
|
||||||
|
PUBLISH_DRY_RUN_RUNNING = "PUBLISH_DRY_RUN_RUNNING"
|
||||||
|
PUBLISH_DRY_RUN_FAILED = "PUBLISH_DRY_RUN_FAILED"
|
||||||
|
PUBLISH_COMMIT_READY = "PUBLISH_COMMIT_READY"
|
||||||
|
PUBLISH_COMMIT_CREATED = "PUBLISH_COMMIT_CREATED"
|
||||||
|
PUBLISH_VERIFICATION_FAILED = "PUBLISH_VERIFICATION_FAILED"
|
||||||
|
|
||||||
|
|
||||||
|
class AssetStatus(str, Enum):
|
||||||
|
PENDING = "PENDING"
|
||||||
|
GENERATED = "GENERATED"
|
||||||
|
APPROVED = "APPROVED"
|
||||||
|
REJECTED = "REJECTED"
|
||||||
|
|
||||||
|
|
||||||
|
class AssetType(str, Enum):
|
||||||
|
HERO_IMAGE = "hero_image"
|
||||||
|
DIAGRAM = "diagram"
|
||||||
|
TABLE = "table"
|
||||||
|
FLOWCHART = "flowchart"
|
||||||
|
COMPARISON_MATRIX = "comparison_matrix"
|
||||||
|
ARCHITECTURE_DIAGRAM = "architecture_diagram"
|
||||||
|
INLINE_IMAGE = "inline_image"
|
||||||
|
|
||||||
|
|
||||||
|
class PlanReviewStatus(str, Enum):
|
||||||
|
PENDING_REVIEW = "PENDING_REVIEW"
|
||||||
|
APPROVED = "APPROVED"
|
||||||
|
REVISION_REQUESTED = "REVISION_REQUESTED"
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewStatus(str, Enum):
|
||||||
|
PENDING = "PENDING"
|
||||||
|
APPROVED = "APPROVED"
|
||||||
|
CHANGES_REQUESTED = "CHANGES_REQUESTED"
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewType(str, Enum):
|
||||||
|
PLAN = "PLAN"
|
||||||
|
EVIDENCE = "EVIDENCE"
|
||||||
|
ASSET = "ASSET"
|
||||||
|
SEO_LANGUAGE = "SEO_LANGUAGE"
|
||||||
|
FINAL = "FINAL"
|
||||||
|
|
||||||
|
|
||||||
|
class ContentReviewKind(str, Enum):
|
||||||
|
SEO = "SEO"
|
||||||
|
LANGUAGE = "LANGUAGE"
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewSuggestionStatus(str, Enum):
|
||||||
|
PENDING = "PENDING"
|
||||||
|
ACCEPTED = "ACCEPTED"
|
||||||
|
REJECTED = "REJECTED"
|
||||||
|
EDITED = "EDITED"
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptConfigVersionStatus(str, Enum):
|
||||||
|
DRAFT = "DRAFT"
|
||||||
|
ACTIVE = "ACTIVE"
|
||||||
|
DEPRECATED = "DEPRECATED"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateStatus(str, Enum):
|
||||||
|
DRAFT = "DRAFT"
|
||||||
|
ACTIVE = "ACTIVE"
|
||||||
|
ARCHIVED = "ARCHIVED"
|
||||||
@@ -0,0 +1,893 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from .enums import (
|
||||||
|
AgentJobErrorCategory,
|
||||||
|
AgentJobStatus,
|
||||||
|
AgentJobType,
|
||||||
|
ArticleWorkflowStatus,
|
||||||
|
ContentReviewKind,
|
||||||
|
AssetStatus,
|
||||||
|
AssetType,
|
||||||
|
ClaimRiskLevel,
|
||||||
|
ClaimSupportStatus,
|
||||||
|
PlanReviewStatus,
|
||||||
|
PublishingStatus,
|
||||||
|
ReviewStatus,
|
||||||
|
ReviewSuggestionStatus,
|
||||||
|
ReviewType,
|
||||||
|
Role,
|
||||||
|
ScriptConfigVersionStatus,
|
||||||
|
WorkflowTemplateStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
JsonObject = dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class ContractModel(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class UserSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
display_name: str = Field(min_length=1)
|
||||||
|
role: Role
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentUser(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
email: str = Field(min_length=1)
|
||||||
|
display_name: str = Field(min_length=1)
|
||||||
|
role: Role
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentUserResponse(ContractModel):
|
||||||
|
user: CurrentUser
|
||||||
|
|
||||||
|
|
||||||
|
class PublishingRules(ContractModel):
|
||||||
|
repository_url: str = Field(min_length=1)
|
||||||
|
production_branch: str = Field(min_length=1)
|
||||||
|
content_format: str = Field(default="mdx", min_length=1)
|
||||||
|
content_path_template: str = Field(min_length=1)
|
||||||
|
asset_path_template: str = Field(min_length=1)
|
||||||
|
frontmatter_mapping: JsonObject = Field(default_factory=dict)
|
||||||
|
transform_script_version_id: UUID | None = None
|
||||||
|
dry_run_renderer: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TargetSiteConfig(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
slug: str = Field(min_length=1)
|
||||||
|
publishing_type: str = Field(default="git_next", min_length=1)
|
||||||
|
default_language: str = Field(default="en", min_length=2)
|
||||||
|
brand_voice: str = Field(min_length=1)
|
||||||
|
audience: str = Field(min_length=1)
|
||||||
|
seo_rules: JsonObject = Field(default_factory=dict)
|
||||||
|
visual_rules: JsonObject = Field(default_factory=dict)
|
||||||
|
source_rules: JsonObject = Field(default_factory=dict)
|
||||||
|
publishing_rules: PublishingRules
|
||||||
|
active_script_config_version_id: UUID | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TargetSiteConfigCreateRequest(ContractModel):
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
slug: str = Field(min_length=1)
|
||||||
|
publishing_type: str = Field(default="git_next", min_length=1)
|
||||||
|
default_language: str = Field(default="en", min_length=2)
|
||||||
|
brand_voice: str = Field(min_length=1)
|
||||||
|
audience: str = Field(min_length=1)
|
||||||
|
seo_rules: JsonObject = Field(default_factory=dict)
|
||||||
|
visual_rules: JsonObject = Field(default_factory=dict)
|
||||||
|
source_rules: JsonObject = Field(default_factory=dict)
|
||||||
|
publishing_rules: PublishingRules
|
||||||
|
active_script_config_version_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TargetSiteConfigUpdateRequest(ContractModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1)
|
||||||
|
slug: str | None = Field(default=None, min_length=1)
|
||||||
|
publishing_type: str | None = Field(default=None, min_length=1)
|
||||||
|
default_language: str | None = Field(default=None, min_length=2)
|
||||||
|
brand_voice: str | None = Field(default=None, min_length=1)
|
||||||
|
audience: str | None = Field(default=None, min_length=1)
|
||||||
|
seo_rules: JsonObject | None = None
|
||||||
|
visual_rules: JsonObject | None = None
|
||||||
|
source_rules: JsonObject | None = None
|
||||||
|
publishing_rules: PublishingRules | None = None
|
||||||
|
active_script_config_version_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TargetSiteConfigResponse(ContractModel):
|
||||||
|
site: TargetSiteConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptConfigVersionSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
target_site_id: UUID
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
status: ScriptConfigVersionStatus
|
||||||
|
diff: JsonObject = Field(default_factory=dict)
|
||||||
|
publishing_yaml_hash: str = Field(min_length=1)
|
||||||
|
transform_script_hash: str = Field(min_length=1)
|
||||||
|
rollback_target_version_id: UUID | None = None
|
||||||
|
created_by: UUID
|
||||||
|
created_at: datetime
|
||||||
|
activated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptConfigVersionCreateRequest(ContractModel):
|
||||||
|
publishing_yaml: str = Field(min_length=1)
|
||||||
|
transform_script: str = Field(min_length=1)
|
||||||
|
diff: JsonObject = Field(default_factory=dict)
|
||||||
|
rollback_target_version_id: UUID | None = None
|
||||||
|
activate: bool = False
|
||||||
|
version: int | None = Field(default=None, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptConfigVersionResponse(ContractModel):
|
||||||
|
version: ScriptConfigVersionSummary
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptConfigVersionListResponse(ContractModel):
|
||||||
|
versions: list[ScriptConfigVersionSummary]
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptConfigVersionAuditEventSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
target_site_id: UUID
|
||||||
|
version_id: UUID
|
||||||
|
event_type: str = Field(min_length=1)
|
||||||
|
actor_user_id: UUID | None = None
|
||||||
|
payload: JsonObject = Field(default_factory=dict)
|
||||||
|
created_at: datetime
|
||||||
|
version: int | None = Field(default=None, ge=1)
|
||||||
|
diff: JsonObject = Field(default_factory=dict)
|
||||||
|
rollback_target_version_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptConfigVersionAuditEventListResponse(ContractModel):
|
||||||
|
events: list[ScriptConfigVersionAuditEventSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStagePart(ContractModel):
|
||||||
|
key: str = Field(min_length=1)
|
||||||
|
type: str = Field(min_length=1)
|
||||||
|
title: str = Field(min_length=1)
|
||||||
|
payload: JsonObject = Field(default_factory=dict)
|
||||||
|
acceptance_criteria: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStageSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
workflow_id: UUID
|
||||||
|
stable_key: str = Field(min_length=1)
|
||||||
|
display_name: str = Field(min_length=1)
|
||||||
|
description: str = Field(min_length=1)
|
||||||
|
position: int = Field(ge=1)
|
||||||
|
owner_role: Role
|
||||||
|
runner_profile_key: str = Field(min_length=1)
|
||||||
|
required_inputs: list[str] = Field(default_factory=list)
|
||||||
|
expected_outputs: list[str] = Field(default_factory=list)
|
||||||
|
acceptance_criteria: list[str] = Field(default_factory=list)
|
||||||
|
requires_human_approval: bool = False
|
||||||
|
retry_policy: JsonObject = Field(default_factory=dict)
|
||||||
|
parts: list[WorkflowStagePart] = Field(default_factory=list)
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
slug: str = Field(min_length=1)
|
||||||
|
description: str = Field(min_length=1)
|
||||||
|
status: WorkflowTemplateStatus
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
created_by: UUID
|
||||||
|
updated_by: UUID | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
activated_at: datetime | None = None
|
||||||
|
archived_at: datetime | None = None
|
||||||
|
stages: list[WorkflowStageSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateCreateRequest(ContractModel):
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
slug: str = Field(min_length=1)
|
||||||
|
description: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateUpdateRequest(ContractModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1)
|
||||||
|
slug: str | None = Field(default=None, min_length=1)
|
||||||
|
description: str | None = Field(default=None, min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStageCreateRequest(ContractModel):
|
||||||
|
stable_key: str = Field(min_length=1)
|
||||||
|
display_name: str = Field(min_length=1)
|
||||||
|
description: str = Field(min_length=1)
|
||||||
|
position: int | None = Field(default=None, ge=1)
|
||||||
|
owner_role: Role
|
||||||
|
runner_profile_key: str = Field(min_length=1)
|
||||||
|
required_inputs: list[str] = Field(default_factory=list)
|
||||||
|
expected_outputs: list[str] = Field(default_factory=list)
|
||||||
|
acceptance_criteria: list[str] = Field(default_factory=list)
|
||||||
|
requires_human_approval: bool = False
|
||||||
|
retry_policy: JsonObject = Field(default_factory=dict)
|
||||||
|
parts: list[WorkflowStagePart] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStageUpdateRequest(ContractModel):
|
||||||
|
stable_key: str | None = Field(default=None, min_length=1)
|
||||||
|
display_name: str | None = Field(default=None, min_length=1)
|
||||||
|
description: str | None = Field(default=None, min_length=1)
|
||||||
|
position: int | None = Field(default=None, ge=1)
|
||||||
|
owner_role: Role | None = None
|
||||||
|
runner_profile_key: str | None = Field(default=None, min_length=1)
|
||||||
|
required_inputs: list[str] | None = None
|
||||||
|
expected_outputs: list[str] | None = None
|
||||||
|
acceptance_criteria: list[str] | None = None
|
||||||
|
requires_human_approval: bool | None = None
|
||||||
|
retry_policy: JsonObject | None = None
|
||||||
|
parts: list[WorkflowStagePart] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStageReorderRequest(ContractModel):
|
||||||
|
stage_ids: list[UUID] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateResponse(ContractModel):
|
||||||
|
workflow: WorkflowTemplateSummary
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateListResponse(ContractModel):
|
||||||
|
workflows: list[WorkflowTemplateSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStageResponse(ContractModel):
|
||||||
|
stage: WorkflowStageSummary
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateAuditEventSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
workflow_id: UUID
|
||||||
|
event_type: str = Field(min_length=1)
|
||||||
|
actor_user_id: UUID | None = None
|
||||||
|
payload: JsonObject = Field(default_factory=dict)
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateAuditEventListResponse(ContractModel):
|
||||||
|
events: list[WorkflowTemplateAuditEventSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateStageSnapshot(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
stable_key: str = Field(min_length=1)
|
||||||
|
display_name: str = Field(min_length=1)
|
||||||
|
position: int = Field(ge=1)
|
||||||
|
owner_role: Role
|
||||||
|
runner_profile_key: str = Field(min_length=1)
|
||||||
|
requires_human_approval: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateSnapshot(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
slug: str = Field(min_length=1)
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
stage_summary: list[WorkflowTemplateStageSnapshot] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleCreateRequest(ContractModel):
|
||||||
|
target_site_id: UUID
|
||||||
|
workflow_template_id: UUID | None = None
|
||||||
|
brief_description: str = Field(min_length=1)
|
||||||
|
working_title: str | None = None
|
||||||
|
language: str = Field(default="en", min_length=2)
|
||||||
|
content_type: str = Field(default="longform_guide", min_length=1)
|
||||||
|
primary_keyword: str | None = None
|
||||||
|
assigned_editor_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
target_site_id: UUID
|
||||||
|
workflow_template_id: UUID | None = None
|
||||||
|
workflow_template_version: int | None = Field(default=None, ge=1)
|
||||||
|
status: ArticleWorkflowStatus
|
||||||
|
publishing_status: PublishingStatus
|
||||||
|
brief_description: str
|
||||||
|
working_title: str | None = None
|
||||||
|
language: str
|
||||||
|
content_type: str
|
||||||
|
primary_keyword: str | None = None
|
||||||
|
assigned_editor_id: UUID | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleCreateResponse(ContractModel):
|
||||||
|
article: ArticleSummary
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleListResponse(ContractModel):
|
||||||
|
articles: list[ArticleSummary]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowEventSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
article_id: UUID
|
||||||
|
event_type: str = Field(min_length=1)
|
||||||
|
from_status: ArticleWorkflowStatus | None = None
|
||||||
|
to_status: ArticleWorkflowStatus | None = None
|
||||||
|
actor_user_id: UUID | None = None
|
||||||
|
payload: JsonObject = Field(default_factory=dict)
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ObservabilityTimelineEventSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
article_id: UUID
|
||||||
|
entry_type: str = Field(min_length=1)
|
||||||
|
source: str = Field(min_length=1)
|
||||||
|
event_type: str = Field(min_length=1)
|
||||||
|
from_status: ArticleWorkflowStatus | None = None
|
||||||
|
to_status: ArticleWorkflowStatus | None = None
|
||||||
|
actor_user_id: UUID | None = None
|
||||||
|
job_id: UUID | None = None
|
||||||
|
job_type: AgentJobType | None = None
|
||||||
|
job_status: AgentJobStatus | None = None
|
||||||
|
retry_eligible: bool = False
|
||||||
|
cancel_eligible: bool = False
|
||||||
|
safe_failure_summary: str | None = None
|
||||||
|
payload: JsonObject = Field(default_factory=dict)
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class BoundaryQuestionSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
article_id: UUID
|
||||||
|
sort_order: int = Field(ge=1)
|
||||||
|
category: str = Field(min_length=1)
|
||||||
|
question: str = Field(min_length=1)
|
||||||
|
answer: str | None = None
|
||||||
|
is_required: bool = True
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class BoundaryQuestionUpdateRequest(ContractModel):
|
||||||
|
answer: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BoundaryQuestionResponse(ContractModel):
|
||||||
|
question: BoundaryQuestionSummary
|
||||||
|
|
||||||
|
|
||||||
|
class BoundaryQuestionListResponse(ContractModel):
|
||||||
|
questions: list[BoundaryQuestionSummary]
|
||||||
|
job: AgentJobSummary | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlanSectionSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
article_plan_id: UUID
|
||||||
|
sort_order: int = Field(ge=1)
|
||||||
|
heading: str = Field(min_length=1)
|
||||||
|
purpose: str | None = None
|
||||||
|
key_points: list[str] = Field(default_factory=list)
|
||||||
|
evidence_needs: list[str] = Field(default_factory=list)
|
||||||
|
claims_to_support: list[str] = Field(default_factory=list)
|
||||||
|
target_word_count: int | None = Field(default=None, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class PlanSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
article_id: UUID
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
status: PlanReviewStatus
|
||||||
|
title_options: list[str] = Field(default_factory=list)
|
||||||
|
recommended_title: str | None = None
|
||||||
|
reader_persona: str | None = None
|
||||||
|
search_intent: str | None = None
|
||||||
|
thesis: str | None = None
|
||||||
|
sections: list[PlanSectionSummary] = Field(default_factory=list)
|
||||||
|
claims_to_prove: list[str] = Field(default_factory=list)
|
||||||
|
evidence_needs: list[str] = Field(default_factory=list)
|
||||||
|
visual_needs: list[str] = Field(default_factory=list)
|
||||||
|
seo_notes: list[str] = Field(default_factory=list)
|
||||||
|
source_requirements: list[str] = Field(default_factory=list)
|
||||||
|
excluded_sources: list[str] = Field(default_factory=list)
|
||||||
|
tone: str | None = None
|
||||||
|
audience: str | None = None
|
||||||
|
risks: list[str] = Field(default_factory=list)
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PlanSectionEditRequest(ContractModel):
|
||||||
|
heading: str = Field(min_length=1)
|
||||||
|
purpose: str | None = None
|
||||||
|
key_points: list[str] = Field(default_factory=list)
|
||||||
|
evidence_needs: list[str] = Field(default_factory=list)
|
||||||
|
claims_to_support: list[str] = Field(default_factory=list)
|
||||||
|
target_word_count: int | None = Field(default=None, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class PlanUpdateRequest(ContractModel):
|
||||||
|
title_options: list[str] | None = None
|
||||||
|
recommended_title: str | None = None
|
||||||
|
reader_persona: str | None = None
|
||||||
|
search_intent: str | None = None
|
||||||
|
thesis: str | None = None
|
||||||
|
sections: list[PlanSectionEditRequest] | None = None
|
||||||
|
claims_to_prove: list[str] | None = None
|
||||||
|
evidence_needs: list[str] | None = None
|
||||||
|
visual_needs: list[str] | None = None
|
||||||
|
seo_notes: list[str] | None = None
|
||||||
|
source_requirements: list[str] | None = None
|
||||||
|
excluded_sources: list[str] | None = None
|
||||||
|
tone: str | None = None
|
||||||
|
audience: str | None = None
|
||||||
|
risks: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlanRevisionRequest(ContractModel):
|
||||||
|
notes: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class PlanResponse(ContractModel):
|
||||||
|
plan: PlanSummary
|
||||||
|
|
||||||
|
|
||||||
|
class PlanListResponse(ContractModel):
|
||||||
|
plans: list[PlanSummary]
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchArtifactSummary(ContractModel):
|
||||||
|
artifact_type: str = Field(min_length=1)
|
||||||
|
source_url: str = Field(min_length=1)
|
||||||
|
object_key: str = Field(min_length=1)
|
||||||
|
content_hash: str = Field(min_length=1)
|
||||||
|
metadata: JsonObject = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchArtifactManifestSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
article_id: UUID
|
||||||
|
agent_job_id: UUID
|
||||||
|
s3_prefix: str = Field(min_length=1)
|
||||||
|
artifacts: list[ResearchArtifactSummary] = Field(default_factory=list)
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchStartResponse(ContractModel):
|
||||||
|
article: ArticleSummary
|
||||||
|
job: AgentJobSummary
|
||||||
|
manifest: ResearchArtifactManifestSummary
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchListResponse(ContractModel):
|
||||||
|
manifests: list[ResearchArtifactManifestSummary] = Field(default_factory=list)
|
||||||
|
evidence: list["EvidenceSummary"] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
article_id: UUID
|
||||||
|
source_title: str = Field(min_length=1)
|
||||||
|
source_url: str = Field(min_length=1)
|
||||||
|
source_type: str = Field(min_length=1)
|
||||||
|
source_quality_score: float = Field(ge=0, le=1)
|
||||||
|
summary: str = Field(min_length=1)
|
||||||
|
supports_claims: list[UUID] = Field(default_factory=list)
|
||||||
|
artifact_manifest_id: UUID
|
||||||
|
retrieved_at: datetime
|
||||||
|
review_status: str = Field(default="PENDING", min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceUpdateRequest(ContractModel):
|
||||||
|
review_status: str | None = Field(default=None, pattern="^(PENDING|APPROVED|REJECTED)$")
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceCreateRequest(ContractModel):
|
||||||
|
source_title: str = Field(min_length=1)
|
||||||
|
source_url: str = Field(min_length=1)
|
||||||
|
source_type: str = Field(min_length=1)
|
||||||
|
summary: str = Field(min_length=1)
|
||||||
|
source_quality_score: float = Field(ge=0, le=1, default=0.7)
|
||||||
|
section_id: UUID | None = None
|
||||||
|
claim_text: str | None = None
|
||||||
|
support_status: ClaimSupportStatus = ClaimSupportStatus.SUPPORTED
|
||||||
|
risk_level: ClaimRiskLevel = ClaimRiskLevel.LOW
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceResponse(ContractModel):
|
||||||
|
evidence: EvidenceSummary
|
||||||
|
|
||||||
|
|
||||||
|
class ClaimSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
article_id: UUID
|
||||||
|
section_id: UUID | None = None
|
||||||
|
claim_text: str = Field(min_length=1)
|
||||||
|
support_status: ClaimSupportStatus
|
||||||
|
risk_level: ClaimRiskLevel
|
||||||
|
evidence_item_ids: list[UUID] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceMatrixResponse(ContractModel):
|
||||||
|
article: ArticleSummary
|
||||||
|
evidence: list[EvidenceSummary] = Field(default_factory=list)
|
||||||
|
claims: list[ClaimSummary] = Field(default_factory=list)
|
||||||
|
insufficient_evidence_reasons: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class DraftFaqItem(ContractModel):
|
||||||
|
question: str = Field(min_length=1)
|
||||||
|
answer: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class DraftSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
article_id: UUID
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
title: str = Field(min_length=1)
|
||||||
|
slug: str = Field(min_length=1)
|
||||||
|
meta_title: str | None = None
|
||||||
|
meta_description: str | None = None
|
||||||
|
body_object_key: str | None = None
|
||||||
|
body_markdown: str = ""
|
||||||
|
faq_items: list[DraftFaqItem] = Field(default_factory=list)
|
||||||
|
visual_placeholders: list[str] = Field(default_factory=list)
|
||||||
|
evidence_references: list[UUID] = Field(default_factory=list)
|
||||||
|
unsupported_claim_warnings: list[str] = Field(default_factory=list)
|
||||||
|
based_on_draft_id: UUID | None = None
|
||||||
|
status: ArticleWorkflowStatus
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AssetRevisionSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
asset_id: UUID
|
||||||
|
article_id: UUID
|
||||||
|
revision_index: int = Field(ge=1)
|
||||||
|
action: str = Field(min_length=1)
|
||||||
|
actor_user_id: UUID | None = None
|
||||||
|
payload: JsonObject = Field(default_factory=dict)
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AssetSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
article_id: UUID
|
||||||
|
section_id: UUID | None = None
|
||||||
|
asset_type: AssetType
|
||||||
|
title: str = Field(min_length=1)
|
||||||
|
prompt: str | None = None
|
||||||
|
object_key: str | None = None
|
||||||
|
file_url: str | None = None
|
||||||
|
alt_text: str | None = None
|
||||||
|
caption: str | None = None
|
||||||
|
status: AssetStatus
|
||||||
|
history: list[AssetRevisionSummary] = Field(default_factory=list)
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
article_id: UUID
|
||||||
|
review_type: ReviewType
|
||||||
|
status: ReviewStatus
|
||||||
|
reviewer_id: UUID | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewActionResponse(ContractModel):
|
||||||
|
review: ReviewSummary
|
||||||
|
|
||||||
|
|
||||||
|
class PublishCommitSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
article_id: UUID
|
||||||
|
target_site_id: UUID
|
||||||
|
repository_url: str = Field(min_length=1)
|
||||||
|
branch: str = Field(min_length=1)
|
||||||
|
commit_sha: str | None = None
|
||||||
|
content_bundle_manifest: JsonObject = Field(default_factory=dict)
|
||||||
|
status: PublishingStatus
|
||||||
|
deployment_status: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PublishingDryRunResponse(ContractModel):
|
||||||
|
article: ArticleSummary
|
||||||
|
publish_commit: PublishCommitSummary
|
||||||
|
content_shape_valid: bool
|
||||||
|
validation_label: str = Field(min_length=1)
|
||||||
|
errors: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PublishCommitCreateResponse(ContractModel):
|
||||||
|
article: ArticleSummary
|
||||||
|
publish_commit: PublishCommitSummary
|
||||||
|
|
||||||
|
|
||||||
|
class PublishingStatusResponse(ContractModel):
|
||||||
|
article: ArticleSummary
|
||||||
|
latest_dry_run: PublishCommitSummary | None = None
|
||||||
|
latest_publish_commit: PublishCommitSummary | None = None
|
||||||
|
validation_label: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class PublishCommitListResponse(ContractModel):
|
||||||
|
commits: list[PublishCommitSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RunnerFileRef(ContractModel):
|
||||||
|
path: str = Field(min_length=1)
|
||||||
|
content_hash: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentJobSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
article_id: UUID | None = None
|
||||||
|
parent_job_id: UUID | None = None
|
||||||
|
attempt: int = Field(default=1, ge=1)
|
||||||
|
job_type: AgentJobType
|
||||||
|
agent_profile: str = Field(min_length=1)
|
||||||
|
status: AgentJobStatus
|
||||||
|
workspace_path: str | None = None
|
||||||
|
input_files: list[RunnerFileRef] = Field(default_factory=list)
|
||||||
|
output_files: list[RunnerFileRef] = Field(default_factory=list)
|
||||||
|
payload: JsonObject = Field(default_factory=dict)
|
||||||
|
error_category: AgentJobErrorCategory | None = None
|
||||||
|
error_message: str | None = None
|
||||||
|
stdout: str = ""
|
||||||
|
stderr: str = ""
|
||||||
|
exit_code: int | None = None
|
||||||
|
duration_ms: int | None = Field(default=None, ge=0)
|
||||||
|
queued_at: datetime
|
||||||
|
started_at: datetime | None = None
|
||||||
|
finished_at: datetime | None = None
|
||||||
|
retry_eligible: bool = False
|
||||||
|
retry_block_reason: str | None = None
|
||||||
|
cancel_eligible: bool = False
|
||||||
|
safe_failure_summary: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentJobTestCodexRequest(ContractModel):
|
||||||
|
fake_result: str = Field(default="valid", pattern="^(valid|invalid_schema)$")
|
||||||
|
|
||||||
|
|
||||||
|
class AgentJobResponse(ContractModel):
|
||||||
|
job: AgentJobSummary
|
||||||
|
|
||||||
|
|
||||||
|
class AgentJobListResponse(ContractModel):
|
||||||
|
jobs: list[AgentJobSummary]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentJobOutput(ContractModel):
|
||||||
|
status: AgentJobStatus
|
||||||
|
output_files: list[RunnerFileRef] = Field(default_factory=list)
|
||||||
|
error_category: AgentJobErrorCategory | None = None
|
||||||
|
error_message: str | None = None
|
||||||
|
artifacts: list[ResearchArtifactSummary] = Field(default_factory=list)
|
||||||
|
payload: JsonObject = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class DraftAssembleResponse(ContractModel):
|
||||||
|
draft: DraftSummary
|
||||||
|
|
||||||
|
|
||||||
|
class DraftListResponse(ContractModel):
|
||||||
|
drafts: list[DraftSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class DraftResponse(ContractModel):
|
||||||
|
draft: DraftSummary
|
||||||
|
|
||||||
|
|
||||||
|
class DraftUpdateRequest(ContractModel):
|
||||||
|
title: str | None = Field(default=None, min_length=1)
|
||||||
|
meta_title: str | None = None
|
||||||
|
meta_description: str | None = None
|
||||||
|
body_markdown: str | None = None
|
||||||
|
faq_items: list[DraftFaqItem] | None = None
|
||||||
|
visual_placeholders: list[str] | None = None
|
||||||
|
evidence_references: list[UUID] | None = None
|
||||||
|
unsupported_claim_warnings: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContentReviewIssueSummary(ContractModel):
|
||||||
|
id: str = Field(min_length=1)
|
||||||
|
suggestion_id: UUID
|
||||||
|
severity: str = Field(min_length=1)
|
||||||
|
location: str = Field(min_length=1)
|
||||||
|
message: str = Field(min_length=1)
|
||||||
|
suggested_fix: str | None = None
|
||||||
|
suggested_rewrite: str | None = None
|
||||||
|
status: ReviewSuggestionStatus
|
||||||
|
|
||||||
|
|
||||||
|
class ContentReviewReportSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
article_id: UUID
|
||||||
|
review_kind: ContentReviewKind
|
||||||
|
draft_id: UUID
|
||||||
|
score: int = Field(ge=0, le=100)
|
||||||
|
issues: list[ContentReviewIssueSummary] = Field(default_factory=list)
|
||||||
|
recommended_slug: str = Field(min_length=1)
|
||||||
|
recommended_title: str = Field(min_length=1)
|
||||||
|
schema_json: JsonObject = Field(default_factory=dict)
|
||||||
|
rules_snapshot: JsonObject = Field(default_factory=dict)
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ContentReviewSuggestionSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
article_id: UUID
|
||||||
|
review_kind: ContentReviewKind
|
||||||
|
report_id: UUID
|
||||||
|
suggestion_key: str = Field(min_length=1)
|
||||||
|
severity: str = Field(min_length=1)
|
||||||
|
location: str = Field(min_length=1)
|
||||||
|
message: str = Field(min_length=1)
|
||||||
|
suggested_fix: str | None = None
|
||||||
|
suggested_rewrite: str | None = None
|
||||||
|
patch: JsonObject = Field(default_factory=dict)
|
||||||
|
status: ReviewSuggestionStatus
|
||||||
|
applied_text: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SeoReviewRunResponse(ContractModel):
|
||||||
|
report: ContentReviewReportSummary
|
||||||
|
|
||||||
|
|
||||||
|
class SeoReviewReportResponse(ContractModel):
|
||||||
|
report: ContentReviewReportSummary
|
||||||
|
|
||||||
|
|
||||||
|
class LanguageReviewRunResponse(ContractModel):
|
||||||
|
report: ContentReviewReportSummary
|
||||||
|
|
||||||
|
|
||||||
|
class LanguageReviewReportResponse(ContractModel):
|
||||||
|
report: ContentReviewReportSummary
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewSuggestionActionResponse(ContractModel):
|
||||||
|
suggestion: ContentReviewSuggestionSummary
|
||||||
|
draft: DraftSummary | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewSuggestionEditRequest(ContractModel):
|
||||||
|
applied_text: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class FinalReviewIssueSummary(ContractModel):
|
||||||
|
review_kind: ContentReviewKind
|
||||||
|
suggestion_id: UUID
|
||||||
|
severity: str = Field(min_length=1)
|
||||||
|
location: str = Field(min_length=1)
|
||||||
|
message: str = Field(min_length=1)
|
||||||
|
status: ReviewSuggestionStatus
|
||||||
|
|
||||||
|
|
||||||
|
class FinalReviewIssuesResponse(ContractModel):
|
||||||
|
article_id: UUID
|
||||||
|
unresolved_count: int = Field(ge=0)
|
||||||
|
unresolved_issues: list[FinalReviewIssueSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class FinalApprovalPublishingSettings(ContractModel):
|
||||||
|
content_path: str | None = None
|
||||||
|
author: str | None = None
|
||||||
|
publishing_mode: str | None = None
|
||||||
|
frontmatter: JsonObject | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FinalApprovalRequest(ContractModel):
|
||||||
|
draft_version: int | None = Field(default=None, ge=1)
|
||||||
|
publishing_settings: FinalApprovalPublishingSettings | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FinalRevisionRequest(ContractModel):
|
||||||
|
reason: str | None = None
|
||||||
|
draft_version: int | None = Field(default=None, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class FinalApprovalChecklistItemSummary(ContractModel):
|
||||||
|
key: str = Field(min_length=1)
|
||||||
|
title: str = Field(min_length=1)
|
||||||
|
completed: bool
|
||||||
|
blocker: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FinalApprovalChecklistResponse(ContractModel):
|
||||||
|
article_id: UUID
|
||||||
|
draft_version: int | None = Field(default=None, ge=1)
|
||||||
|
requested_draft_version: int | None = Field(default=None, ge=1)
|
||||||
|
can_approve: bool
|
||||||
|
completed_items: list[str] = Field(default_factory=list)
|
||||||
|
blockers: list[str] = Field(default_factory=list)
|
||||||
|
items: list[FinalApprovalChecklistItemSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class FinalApprovalResponse(ContractModel):
|
||||||
|
article: ArticleSummary
|
||||||
|
checklist: FinalApprovalChecklistResponse
|
||||||
|
|
||||||
|
|
||||||
|
class FinalRevisionRequestResponse(ContractModel):
|
||||||
|
article: ArticleSummary
|
||||||
|
checklist: FinalApprovalChecklistResponse
|
||||||
|
|
||||||
|
|
||||||
|
class AssetGenerateSpecsResponse(ContractModel):
|
||||||
|
assets: list[AssetSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AssetListResponse(ContractModel):
|
||||||
|
assets: list[AssetSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AssetResponse(ContractModel):
|
||||||
|
asset: AssetSummary
|
||||||
|
|
||||||
|
|
||||||
|
class AssetUpdateRequest(ContractModel):
|
||||||
|
title: str | None = Field(default=None, min_length=1)
|
||||||
|
prompt: str | None = None
|
||||||
|
alt_text: str | None = None
|
||||||
|
caption: str | None = None
|
||||||
|
section_id: UUID | None = None
|
||||||
|
status: AssetStatus | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AssetUploadRequest(ContractModel):
|
||||||
|
filename: str = Field(min_length=1)
|
||||||
|
content_base64: str = Field(min_length=1)
|
||||||
|
content_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AssetUploadResponse(ContractModel):
|
||||||
|
asset: AssetSummary
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleDetailResponse(ContractModel):
|
||||||
|
article: ArticleSummary
|
||||||
|
target_site: TargetSiteConfig | None = None
|
||||||
|
workflow_template_snapshot: WorkflowTemplateSnapshot | None = None
|
||||||
|
workflow_events: list[WorkflowEventSummary] = Field(default_factory=list)
|
||||||
|
boundary_questions: list[BoundaryQuestionSummary] = Field(default_factory=list)
|
||||||
|
plan: PlanSummary | None = None
|
||||||
|
draft: DraftSummary | None = None
|
||||||
|
evidence: list[EvidenceSummary] = Field(default_factory=list)
|
||||||
|
claims: list[ClaimSummary] = Field(default_factory=list)
|
||||||
|
assets: list[AssetSummary] = Field(default_factory=list)
|
||||||
|
reviews: list[ReviewSummary] = Field(default_factory=list)
|
||||||
|
agent_jobs: list[AgentJobSummary] = Field(default_factory=list)
|
||||||
|
timeline: list[ObservabilityTimelineEventSummary] = Field(default_factory=list)
|
||||||
|
research_manifests: list[ResearchArtifactManifestSummary] = Field(
|
||||||
|
default_factory=list
|
||||||
|
)
|
||||||
|
publish_commit: PublishCommitSummary | None = None
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, TypeAdapter
|
||||||
|
|
||||||
|
from .enums import (
|
||||||
|
AgentJobErrorCategory,
|
||||||
|
AgentJobStatus,
|
||||||
|
AgentJobType,
|
||||||
|
ArticleWorkflowStatus,
|
||||||
|
ContentReviewKind,
|
||||||
|
AssetStatus,
|
||||||
|
AssetType,
|
||||||
|
ClaimRiskLevel,
|
||||||
|
ClaimSupportStatus,
|
||||||
|
PlanReviewStatus,
|
||||||
|
PublishingStatus,
|
||||||
|
ReviewStatus,
|
||||||
|
ReviewSuggestionStatus,
|
||||||
|
ReviewType,
|
||||||
|
Role,
|
||||||
|
ScriptConfigVersionStatus,
|
||||||
|
WorkflowTemplateStatus,
|
||||||
|
)
|
||||||
|
from .models import (
|
||||||
|
AgentJobOutput,
|
||||||
|
AgentJobListResponse,
|
||||||
|
AgentJobResponse,
|
||||||
|
AgentJobSummary,
|
||||||
|
AgentJobTestCodexRequest,
|
||||||
|
ArticleCreateRequest,
|
||||||
|
ArticleCreateResponse,
|
||||||
|
ArticleDetailResponse,
|
||||||
|
ArticleListResponse,
|
||||||
|
ArticleSummary,
|
||||||
|
BoundaryQuestionListResponse,
|
||||||
|
BoundaryQuestionResponse,
|
||||||
|
BoundaryQuestionSummary,
|
||||||
|
BoundaryQuestionUpdateRequest,
|
||||||
|
ObservabilityTimelineEventSummary,
|
||||||
|
AssetGenerateSpecsResponse,
|
||||||
|
AssetListResponse,
|
||||||
|
AssetResponse,
|
||||||
|
AssetRevisionSummary,
|
||||||
|
AssetSummary,
|
||||||
|
AssetUpdateRequest,
|
||||||
|
AssetUploadRequest,
|
||||||
|
AssetUploadResponse,
|
||||||
|
ClaimSummary,
|
||||||
|
CurrentUser,
|
||||||
|
CurrentUserResponse,
|
||||||
|
DraftAssembleResponse,
|
||||||
|
DraftFaqItem,
|
||||||
|
DraftListResponse,
|
||||||
|
DraftResponse,
|
||||||
|
DraftSummary,
|
||||||
|
DraftUpdateRequest,
|
||||||
|
ContentReviewIssueSummary,
|
||||||
|
ContentReviewReportSummary,
|
||||||
|
ContentReviewSuggestionSummary,
|
||||||
|
FinalApprovalChecklistItemSummary,
|
||||||
|
FinalApprovalChecklistResponse,
|
||||||
|
FinalApprovalPublishingSettings,
|
||||||
|
FinalApprovalRequest,
|
||||||
|
FinalApprovalResponse,
|
||||||
|
EvidenceSummary,
|
||||||
|
EvidenceCreateRequest,
|
||||||
|
EvidenceMatrixResponse,
|
||||||
|
EvidenceResponse,
|
||||||
|
EvidenceUpdateRequest,
|
||||||
|
PlanSectionSummary,
|
||||||
|
PlanSectionEditRequest,
|
||||||
|
PlanListResponse,
|
||||||
|
PlanResponse,
|
||||||
|
PlanRevisionRequest,
|
||||||
|
PlanSummary,
|
||||||
|
PlanUpdateRequest,
|
||||||
|
PublishCommitCreateResponse,
|
||||||
|
PublishCommitListResponse,
|
||||||
|
PublishCommitSummary,
|
||||||
|
PublishingDryRunResponse,
|
||||||
|
PublishingStatusResponse,
|
||||||
|
PublishingRules,
|
||||||
|
ResearchArtifactManifestSummary,
|
||||||
|
ResearchArtifactSummary,
|
||||||
|
ResearchListResponse,
|
||||||
|
ResearchStartResponse,
|
||||||
|
FinalReviewIssueSummary,
|
||||||
|
FinalReviewIssuesResponse,
|
||||||
|
LanguageReviewReportResponse,
|
||||||
|
LanguageReviewRunResponse,
|
||||||
|
ReviewSuggestionActionResponse,
|
||||||
|
ReviewSuggestionEditRequest,
|
||||||
|
ReviewActionResponse,
|
||||||
|
ReviewSummary,
|
||||||
|
FinalRevisionRequest,
|
||||||
|
FinalRevisionRequestResponse,
|
||||||
|
SeoReviewReportResponse,
|
||||||
|
SeoReviewRunResponse,
|
||||||
|
RunnerFileRef,
|
||||||
|
ScriptConfigVersionAuditEventListResponse,
|
||||||
|
ScriptConfigVersionAuditEventSummary,
|
||||||
|
ScriptConfigVersionCreateRequest,
|
||||||
|
ScriptConfigVersionListResponse,
|
||||||
|
ScriptConfigVersionResponse,
|
||||||
|
ScriptConfigVersionSummary,
|
||||||
|
TargetSiteConfig,
|
||||||
|
TargetSiteConfigCreateRequest,
|
||||||
|
TargetSiteConfigResponse,
|
||||||
|
WorkflowEventSummary,
|
||||||
|
WorkflowStageCreateRequest,
|
||||||
|
WorkflowStagePart,
|
||||||
|
WorkflowStageReorderRequest,
|
||||||
|
WorkflowStageResponse,
|
||||||
|
WorkflowStageSummary,
|
||||||
|
WorkflowStageUpdateRequest,
|
||||||
|
WorkflowTemplateAuditEventListResponse,
|
||||||
|
WorkflowTemplateAuditEventSummary,
|
||||||
|
WorkflowTemplateCreateRequest,
|
||||||
|
WorkflowTemplateListResponse,
|
||||||
|
WorkflowTemplateResponse,
|
||||||
|
WorkflowTemplateSnapshot,
|
||||||
|
WorkflowTemplateStageSnapshot,
|
||||||
|
WorkflowTemplateSummary,
|
||||||
|
WorkflowTemplateUpdateRequest,
|
||||||
|
TargetSiteConfigUpdateRequest,
|
||||||
|
UserSummary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CONTRACT_ENUMS: tuple[type[Enum], ...] = (
|
||||||
|
Role,
|
||||||
|
ArticleWorkflowStatus,
|
||||||
|
AgentJobStatus,
|
||||||
|
AgentJobType,
|
||||||
|
AgentJobErrorCategory,
|
||||||
|
ClaimSupportStatus,
|
||||||
|
ClaimRiskLevel,
|
||||||
|
PublishingStatus,
|
||||||
|
AssetStatus,
|
||||||
|
AssetType,
|
||||||
|
PlanReviewStatus,
|
||||||
|
ReviewStatus,
|
||||||
|
ReviewSuggestionStatus,
|
||||||
|
ReviewType,
|
||||||
|
ContentReviewKind,
|
||||||
|
ScriptConfigVersionStatus,
|
||||||
|
WorkflowTemplateStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
||||||
|
UserSummary,
|
||||||
|
CurrentUser,
|
||||||
|
CurrentUserResponse,
|
||||||
|
PublishingRules,
|
||||||
|
TargetSiteConfig,
|
||||||
|
TargetSiteConfigCreateRequest,
|
||||||
|
TargetSiteConfigUpdateRequest,
|
||||||
|
TargetSiteConfigResponse,
|
||||||
|
ScriptConfigVersionSummary,
|
||||||
|
ScriptConfigVersionCreateRequest,
|
||||||
|
ScriptConfigVersionResponse,
|
||||||
|
ScriptConfigVersionListResponse,
|
||||||
|
ScriptConfigVersionAuditEventSummary,
|
||||||
|
ScriptConfigVersionAuditEventListResponse,
|
||||||
|
WorkflowStagePart,
|
||||||
|
WorkflowStageSummary,
|
||||||
|
WorkflowTemplateSummary,
|
||||||
|
WorkflowTemplateCreateRequest,
|
||||||
|
WorkflowTemplateUpdateRequest,
|
||||||
|
WorkflowStageCreateRequest,
|
||||||
|
WorkflowStageUpdateRequest,
|
||||||
|
WorkflowStageReorderRequest,
|
||||||
|
WorkflowTemplateResponse,
|
||||||
|
WorkflowTemplateListResponse,
|
||||||
|
WorkflowStageResponse,
|
||||||
|
WorkflowTemplateAuditEventSummary,
|
||||||
|
WorkflowTemplateAuditEventListResponse,
|
||||||
|
WorkflowTemplateStageSnapshot,
|
||||||
|
WorkflowTemplateSnapshot,
|
||||||
|
ArticleCreateRequest,
|
||||||
|
ArticleSummary,
|
||||||
|
ArticleCreateResponse,
|
||||||
|
ArticleListResponse,
|
||||||
|
WorkflowEventSummary,
|
||||||
|
ObservabilityTimelineEventSummary,
|
||||||
|
BoundaryQuestionSummary,
|
||||||
|
BoundaryQuestionUpdateRequest,
|
||||||
|
BoundaryQuestionResponse,
|
||||||
|
BoundaryQuestionListResponse,
|
||||||
|
PlanSectionSummary,
|
||||||
|
PlanSectionEditRequest,
|
||||||
|
PlanSummary,
|
||||||
|
PlanUpdateRequest,
|
||||||
|
PlanRevisionRequest,
|
||||||
|
PlanResponse,
|
||||||
|
PlanListResponse,
|
||||||
|
ResearchArtifactSummary,
|
||||||
|
ResearchArtifactManifestSummary,
|
||||||
|
ResearchStartResponse,
|
||||||
|
ResearchListResponse,
|
||||||
|
EvidenceSummary,
|
||||||
|
EvidenceCreateRequest,
|
||||||
|
EvidenceUpdateRequest,
|
||||||
|
EvidenceResponse,
|
||||||
|
ClaimSummary,
|
||||||
|
EvidenceMatrixResponse,
|
||||||
|
DraftSummary,
|
||||||
|
DraftFaqItem,
|
||||||
|
DraftAssembleResponse,
|
||||||
|
DraftListResponse,
|
||||||
|
DraftResponse,
|
||||||
|
DraftUpdateRequest,
|
||||||
|
ContentReviewIssueSummary,
|
||||||
|
ContentReviewReportSummary,
|
||||||
|
ContentReviewSuggestionSummary,
|
||||||
|
FinalApprovalChecklistItemSummary,
|
||||||
|
FinalApprovalChecklistResponse,
|
||||||
|
FinalApprovalPublishingSettings,
|
||||||
|
FinalApprovalRequest,
|
||||||
|
FinalApprovalResponse,
|
||||||
|
FinalRevisionRequest,
|
||||||
|
FinalRevisionRequestResponse,
|
||||||
|
FinalReviewIssueSummary,
|
||||||
|
FinalReviewIssuesResponse,
|
||||||
|
LanguageReviewReportResponse,
|
||||||
|
LanguageReviewRunResponse,
|
||||||
|
AssetGenerateSpecsResponse,
|
||||||
|
AssetListResponse,
|
||||||
|
AssetResponse,
|
||||||
|
AssetRevisionSummary,
|
||||||
|
AssetSummary,
|
||||||
|
AssetUpdateRequest,
|
||||||
|
AssetUploadRequest,
|
||||||
|
AssetUploadResponse,
|
||||||
|
ReviewSummary,
|
||||||
|
ReviewActionResponse,
|
||||||
|
ReviewSuggestionActionResponse,
|
||||||
|
ReviewSuggestionEditRequest,
|
||||||
|
SeoReviewReportResponse,
|
||||||
|
SeoReviewRunResponse,
|
||||||
|
PublishCommitSummary,
|
||||||
|
PublishCommitCreateResponse,
|
||||||
|
PublishCommitListResponse,
|
||||||
|
PublishingDryRunResponse,
|
||||||
|
PublishingStatusResponse,
|
||||||
|
RunnerFileRef,
|
||||||
|
AgentJobSummary,
|
||||||
|
AgentJobTestCodexRequest,
|
||||||
|
AgentJobResponse,
|
||||||
|
AgentJobListResponse,
|
||||||
|
AgentJobOutput,
|
||||||
|
ArticleDetailResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def inject_contract_schemas(openapi_schema: dict[str, Any]) -> None:
|
||||||
|
schemas = openapi_schema.setdefault("components", {}).setdefault("schemas", {})
|
||||||
|
|
||||||
|
for enum in CONTRACT_ENUMS:
|
||||||
|
schemas[enum.__name__] = TypeAdapter(enum).json_schema(
|
||||||
|
ref_template="#/components/schemas/{model}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for model in CONTRACT_SCHEMA_MODELS:
|
||||||
|
schema = model.model_json_schema(ref_template="#/components/schemas/{model}")
|
||||||
|
for name, definition in schema.pop("$defs", {}).items():
|
||||||
|
schemas.setdefault(name, definition)
|
||||||
|
schemas[model.__name__] = schema
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .enums import ArticleWorkflowStatus
|
||||||
|
|
||||||
|
|
||||||
|
ARTICLE_WORKFLOW_STATUSES: tuple[ArticleWorkflowStatus, ...] = (
|
||||||
|
ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
|
||||||
|
ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED,
|
||||||
|
ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
|
||||||
|
ArticleWorkflowStatus.PLAN_GENERATED,
|
||||||
|
ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED,
|
||||||
|
ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
|
||||||
|
ArticleWorkflowStatus.RESEARCH_RUNNING,
|
||||||
|
ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
|
||||||
|
ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING,
|
||||||
|
ArticleWorkflowStatus.DRAFT_ASSEMBLED,
|
||||||
|
ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
|
||||||
|
ArticleWorkflowStatus.FINAL_REVIEW_REQUIRED,
|
||||||
|
ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
|
||||||
|
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
|
||||||
|
ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
|
||||||
|
ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ARTICLE_WORKFLOW_TRANSITIONS: dict[
|
||||||
|
ArticleWorkflowStatus, tuple[ArticleWorkflowStatus, ...]
|
||||||
|
] = {
|
||||||
|
ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED: (
|
||||||
|
ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED,
|
||||||
|
),
|
||||||
|
ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED: (
|
||||||
|
ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
|
||||||
|
),
|
||||||
|
ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED: (
|
||||||
|
ArticleWorkflowStatus.PLAN_GENERATED,
|
||||||
|
),
|
||||||
|
ArticleWorkflowStatus.PLAN_GENERATED: (
|
||||||
|
ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED,
|
||||||
|
),
|
||||||
|
ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED: (
|
||||||
|
ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
|
||||||
|
ArticleWorkflowStatus.RESEARCH_RUNNING,
|
||||||
|
),
|
||||||
|
ArticleWorkflowStatus.PLAN_REVISION_REQUIRED: (
|
||||||
|
ArticleWorkflowStatus.PLAN_GENERATED,
|
||||||
|
),
|
||||||
|
ArticleWorkflowStatus.RESEARCH_RUNNING: (
|
||||||
|
ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
|
||||||
|
),
|
||||||
|
ArticleWorkflowStatus.EVIDENCE_MATRIX_READY: (
|
||||||
|
ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING,
|
||||||
|
),
|
||||||
|
ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING: (
|
||||||
|
ArticleWorkflowStatus.DRAFT_ASSEMBLED,
|
||||||
|
),
|
||||||
|
ArticleWorkflowStatus.DRAFT_ASSEMBLED: (
|
||||||
|
ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
|
||||||
|
),
|
||||||
|
ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY: (
|
||||||
|
ArticleWorkflowStatus.FINAL_REVIEW_REQUIRED,
|
||||||
|
),
|
||||||
|
ArticleWorkflowStatus.FINAL_REVIEW_REQUIRED: (
|
||||||
|
ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
|
||||||
|
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
|
||||||
|
),
|
||||||
|
ArticleWorkflowStatus.FINAL_REVISION_REQUIRED: (
|
||||||
|
ArticleWorkflowStatus.DRAFT_ASSEMBLED,
|
||||||
|
),
|
||||||
|
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED: (
|
||||||
|
ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
|
||||||
|
),
|
||||||
|
ArticleWorkflowStatus.PUBLISH_COMMIT_READY: (
|
||||||
|
ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED,
|
||||||
|
),
|
||||||
|
ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED: (),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def next_workflow_statuses(
|
||||||
|
status: ArticleWorkflowStatus,
|
||||||
|
) -> tuple[ArticleWorkflowStatus, ...]:
|
||||||
|
return ARTICLE_WORKFLOW_TRANSITIONS[status]
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
USERS_TABLE = "users"
|
||||||
|
TARGET_SITES_TABLE = "target_sites"
|
||||||
|
SCRIPT_CONFIG_VERSIONS_TABLE = "script_config_versions"
|
||||||
|
WORKFLOW_TEMPLATES_TABLE = "workflow_templates"
|
||||||
|
WORKFLOW_TEMPLATE_STAGES_TABLE = "workflow_template_stages"
|
||||||
|
WORKFLOW_TEMPLATE_EVENTS_TABLE = "workflow_template_events"
|
||||||
|
ARTICLES_TABLE = "articles"
|
||||||
|
BOUNDARY_QUESTIONS_TABLE = "boundary_questions"
|
||||||
|
ARTICLE_PLANS_TABLE = "article_plans"
|
||||||
|
PLAN_SECTIONS_TABLE = "plan_sections"
|
||||||
|
EVIDENCE_ITEMS_TABLE = "evidence_items"
|
||||||
|
CLAIMS_TABLE = "claims"
|
||||||
|
ARTICLE_DRAFTS_TABLE = "article_drafts"
|
||||||
|
ARTICLE_REVIEW_REPORTS_TABLE = "article_review_reports"
|
||||||
|
ARTICLE_REVIEW_SUGGESTIONS_TABLE = "article_review_suggestions"
|
||||||
|
ASSETS_TABLE = "assets"
|
||||||
|
ASSET_REVISIONS_TABLE = "asset_revisions"
|
||||||
|
WORKFLOW_EVENTS_TABLE = "workflow_events"
|
||||||
|
AGENT_JOBS_TABLE = "agent_jobs"
|
||||||
|
RESEARCH_RUN_MANIFESTS_TABLE = "research_run_manifests"
|
||||||
|
PUBLISH_COMMITS_TABLE = "publish_commits"
|
||||||
|
PROMPT_VERSIONS_TABLE = "prompt_versions"
|
||||||
|
|
||||||
|
|
||||||
|
CORE_TABLES: tuple[str, ...] = (
|
||||||
|
USERS_TABLE,
|
||||||
|
TARGET_SITES_TABLE,
|
||||||
|
SCRIPT_CONFIG_VERSIONS_TABLE,
|
||||||
|
WORKFLOW_TEMPLATES_TABLE,
|
||||||
|
WORKFLOW_TEMPLATE_STAGES_TABLE,
|
||||||
|
WORKFLOW_TEMPLATE_EVENTS_TABLE,
|
||||||
|
ARTICLES_TABLE,
|
||||||
|
BOUNDARY_QUESTIONS_TABLE,
|
||||||
|
ARTICLE_PLANS_TABLE,
|
||||||
|
PLAN_SECTIONS_TABLE,
|
||||||
|
EVIDENCE_ITEMS_TABLE,
|
||||||
|
CLAIMS_TABLE,
|
||||||
|
ARTICLE_DRAFTS_TABLE,
|
||||||
|
ARTICLE_REVIEW_REPORTS_TABLE,
|
||||||
|
ARTICLE_REVIEW_SUGGESTIONS_TABLE,
|
||||||
|
ASSETS_TABLE,
|
||||||
|
ASSET_REVISIONS_TABLE,
|
||||||
|
WORKFLOW_EVENTS_TABLE,
|
||||||
|
AGENT_JOBS_TABLE,
|
||||||
|
RESEARCH_RUN_MANIFESTS_TABLE,
|
||||||
|
PUBLISH_COMMITS_TABLE,
|
||||||
|
PROMPT_VERSIONS_TABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SEEDED_ADMIN_EMAIL = "admin@example.com"
|
||||||
|
SEEDED_EDITOR_EMAIL = "editor@example.com"
|
||||||
|
SEEDED_TARGET_SITE_SLUG = "b2b_saas_blog"
|
||||||
@@ -2,3 +2,17 @@
|
|||||||
|
|
||||||
Database, cache, object storage, queue, and other external adapters belong in
|
Database, cache, object storage, queue, and other external adapters belong in
|
||||||
this layer.
|
this layer.
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
Run migrations from the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgresql://pipeline:pipeline_local@localhost:5432/pipeline alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
`POSTGRES_DSN` is also supported. Reference data can be seeded with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgresql://pipeline:pipeline_local@localhost:5432/pipeline make seed
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectStorageClient:
|
||||||
|
def put_text(self, *, object_key: str, content: str) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def get_bytes(self, *, object_key: str) -> bytes:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def put_bytes(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
object_key: str,
|
||||||
|
content: bytes,
|
||||||
|
content_type: str = "application/octet-stream",
|
||||||
|
) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class LocalObjectStorageClient(ObjectStorageClient):
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
self.root = root
|
||||||
|
|
||||||
|
def put_text(self, *, object_key: str, content: str) -> str:
|
||||||
|
path = self.root / object_key
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
return f"file://{path}"
|
||||||
|
|
||||||
|
def put_bytes(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
object_key: str,
|
||||||
|
content: bytes,
|
||||||
|
content_type: str = "application/octet-stream",
|
||||||
|
) -> str:
|
||||||
|
del content_type
|
||||||
|
path = self.root / object_key
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(content)
|
||||||
|
return f"file://{path}"
|
||||||
|
|
||||||
|
def get_bytes(self, *, object_key: str) -> bytes:
|
||||||
|
path = self.root / object_key
|
||||||
|
return path.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
class S3ObjectStorageClient(ObjectStorageClient):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
import boto3
|
||||||
|
from botocore.config import Config
|
||||||
|
|
||||||
|
self.bucket = _required_env("OBJECT_STORAGE_BUCKET")
|
||||||
|
self.client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=_required_env("OBJECT_STORAGE_ENDPOINT"),
|
||||||
|
aws_access_key_id=_required_env("OBJECT_STORAGE_ACCESS_KEY_ID"),
|
||||||
|
aws_secret_access_key=_required_env("OBJECT_STORAGE_SECRET_ACCESS_KEY"),
|
||||||
|
region_name=os.environ.get("OBJECT_STORAGE_REGION", "us-east-1"),
|
||||||
|
config=Config(signature_version="s3v4"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def put_text(self, *, object_key: str, content: str) -> str:
|
||||||
|
return self.put_bytes(
|
||||||
|
object_key=object_key,
|
||||||
|
content=content.encode("utf-8"),
|
||||||
|
content_type="application/json; charset=utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_bytes(self, *, object_key: str) -> bytes:
|
||||||
|
response = self.client.get_object(
|
||||||
|
Bucket=self.bucket,
|
||||||
|
Key=object_key,
|
||||||
|
)
|
||||||
|
body = response.get("Body")
|
||||||
|
if body is None:
|
||||||
|
raise RuntimeError(f"Object storage response missing Body for key: {object_key}")
|
||||||
|
return body.read()
|
||||||
|
|
||||||
|
def put_bytes(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
object_key: str,
|
||||||
|
content: bytes,
|
||||||
|
content_type: str = "application/octet-stream",
|
||||||
|
) -> str:
|
||||||
|
self.client.put_object(
|
||||||
|
Bucket=self.bucket,
|
||||||
|
Key=object_key,
|
||||||
|
Body=content,
|
||||||
|
ContentType=content_type,
|
||||||
|
)
|
||||||
|
return f"s3://{self.bucket}/{object_key}"
|
||||||
|
|
||||||
|
|
||||||
|
def open_object_storage_client() -> ObjectStorageClient:
|
||||||
|
local_root = os.environ.get("OBJECT_STORAGE_LOCAL_ROOT")
|
||||||
|
if local_root:
|
||||||
|
return LocalObjectStorageClient(Path(local_root))
|
||||||
|
|
||||||
|
if os.environ.get("OBJECT_STORAGE_ENDPOINT"):
|
||||||
|
return S3ObjectStorageClient()
|
||||||
|
|
||||||
|
return LocalObjectStorageClient(Path("/tmp/pipeline-object-storage"))
|
||||||
|
|
||||||
|
|
||||||
|
def _required_env(name: str) -> str:
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if not value:
|
||||||
|
raise RuntimeError(f"Missing required environment variable: {name}")
|
||||||
|
return value
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,979 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from contextlib import closing
|
||||||
|
|
||||||
|
from src.domain.contracts import (
|
||||||
|
AgentJobErrorCategory,
|
||||||
|
AgentJobStatus,
|
||||||
|
AgentJobType,
|
||||||
|
ArticleWorkflowStatus,
|
||||||
|
AssetStatus,
|
||||||
|
AssetType,
|
||||||
|
ClaimRiskLevel,
|
||||||
|
ClaimSupportStatus,
|
||||||
|
PlanReviewStatus,
|
||||||
|
PublishingStatus,
|
||||||
|
Role,
|
||||||
|
ScriptConfigVersionStatus,
|
||||||
|
WorkflowTemplateStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _quoted_values(values: Iterable[str]) -> str:
|
||||||
|
return ", ".join(f"'{value}'" for value in values)
|
||||||
|
|
||||||
|
|
||||||
|
ROLE_VALUES = _quoted_values(role.value for role in Role)
|
||||||
|
ARTICLE_STATUS_VALUES = _quoted_values(status.value for status in ArticleWorkflowStatus)
|
||||||
|
PUBLISHING_STATUS_VALUES = _quoted_values(status.value for status in PublishingStatus)
|
||||||
|
SCRIPT_CONFIG_STATUS_VALUES = _quoted_values(
|
||||||
|
status.value for status in ScriptConfigVersionStatus
|
||||||
|
)
|
||||||
|
WORKFLOW_TEMPLATE_STATUS_VALUES = _quoted_values(
|
||||||
|
status.value for status in WorkflowTemplateStatus
|
||||||
|
)
|
||||||
|
PLAN_REVIEW_STATUS_VALUES = _quoted_values(status.value for status in PlanReviewStatus)
|
||||||
|
CLAIM_SUPPORT_STATUS_VALUES = _quoted_values(
|
||||||
|
status.value for status in ClaimSupportStatus
|
||||||
|
)
|
||||||
|
CLAIM_RISK_LEVEL_VALUES = _quoted_values(level.value for level in ClaimRiskLevel)
|
||||||
|
ASSET_TYPE_VALUES = _quoted_values(asset_type.value for asset_type in AssetType)
|
||||||
|
ASSET_STATUS_VALUES = _quoted_values(status.value for status in AssetStatus)
|
||||||
|
AGENT_JOB_TYPE_VALUES = _quoted_values(job_type.value for job_type in AgentJobType)
|
||||||
|
AGENT_JOB_STATUS_VALUES = _quoted_values(status.value for status in AgentJobStatus)
|
||||||
|
AGENT_JOB_ERROR_CATEGORY_VALUES = _quoted_values(
|
||||||
|
category.value for category in AgentJobErrorCategory
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
||||||
|
'CREATE EXTENSION IF NOT EXISTS "pgcrypto"',
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ({ROLE_VALUES})),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS target_sites (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
publishing_type TEXT NOT NULL DEFAULT 'git_next',
|
||||||
|
default_language TEXT NOT NULL DEFAULT 'en',
|
||||||
|
brand_voice TEXT NOT NULL,
|
||||||
|
audience TEXT NOT NULL,
|
||||||
|
seo_rules JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
visual_rules JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
source_rules JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
publishing_rules JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
active_script_config_version_id UUID,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS script_config_versions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
target_site_id UUID NOT NULL REFERENCES target_sites(id) ON DELETE CASCADE,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ({SCRIPT_CONFIG_STATUS_VALUES})),
|
||||||
|
created_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
diff JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
||||||
|
rollback_target_version_id UUID REFERENCES script_config_versions(id),
|
||||||
|
activated_at TIMESTAMPTZ,
|
||||||
|
publishing_yaml TEXT NOT NULL,
|
||||||
|
publishing_yaml_hash TEXT NOT NULL,
|
||||||
|
transform_script TEXT NOT NULL,
|
||||||
|
transform_script_hash TEXT NOT NULL,
|
||||||
|
UNIQUE (target_site_id, version)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS workflow_templates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ({WORKFLOW_TEMPLATE_STATUS_VALUES})),
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
created_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
updated_by UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
activated_at TIMESTAMPTZ,
|
||||||
|
archived_at TIMESTAMPTZ
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS workflow_template_stages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workflow_template_id UUID NOT NULL REFERENCES workflow_templates(id) ON DELETE CASCADE,
|
||||||
|
stable_key TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
owner_role TEXT NOT NULL CHECK (owner_role IN ('ADMIN', 'EDITOR')),
|
||||||
|
runner_profile_key TEXT NOT NULL,
|
||||||
|
required_inputs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
expected_outputs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
acceptance_criteria JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
requires_human_approval BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
retry_policy JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
parts JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (workflow_template_id, stable_key)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS workflow_template_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workflow_template_id UUID NOT NULL REFERENCES workflow_templates(id) ON DELETE CASCADE,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
actor_user_id UUID REFERENCES users(id),
|
||||||
|
payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS articles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
target_site_id UUID NOT NULL REFERENCES target_sites(id),
|
||||||
|
workflow_template_id UUID REFERENCES workflow_templates(id),
|
||||||
|
workflow_template_version INTEGER,
|
||||||
|
workflow_template_snapshot JSONB,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ({ARTICLE_STATUS_VALUES})),
|
||||||
|
publishing_status TEXT NOT NULL CHECK (
|
||||||
|
publishing_status IN ({PUBLISHING_STATUS_VALUES})
|
||||||
|
),
|
||||||
|
brief_description TEXT NOT NULL,
|
||||||
|
working_title TEXT,
|
||||||
|
language TEXT NOT NULL DEFAULT 'en',
|
||||||
|
content_type TEXT NOT NULL DEFAULT 'longform_guide',
|
||||||
|
primary_keyword TEXT,
|
||||||
|
assigned_editor_id UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"ALTER TABLE articles ADD COLUMN IF NOT EXISTS workflow_template_id UUID REFERENCES workflow_templates(id)",
|
||||||
|
"ALTER TABLE articles ADD COLUMN IF NOT EXISTS workflow_template_version INTEGER",
|
||||||
|
"ALTER TABLE articles ADD COLUMN IF NOT EXISTS workflow_template_snapshot JSONB",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS boundary_questions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL,
|
||||||
|
category TEXT NOT NULL DEFAULT 'general',
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
answer TEXT,
|
||||||
|
is_required BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (article_id, sort_order)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"ALTER TABLE boundary_questions ADD COLUMN IF NOT EXISTS category TEXT NOT NULL DEFAULT 'general'",
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS article_plans (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ({PLAN_REVIEW_STATUS_VALUES})),
|
||||||
|
title_options JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
recommended_title TEXT,
|
||||||
|
reader_persona TEXT,
|
||||||
|
search_intent TEXT,
|
||||||
|
thesis TEXT,
|
||||||
|
claims_to_prove JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
evidence_needs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
visual_needs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
seo_notes JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
source_requirements JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
excluded_sources JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
tone TEXT,
|
||||||
|
audience TEXT,
|
||||||
|
risks JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (article_id, version)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS title_options JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||||
|
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS reader_persona TEXT",
|
||||||
|
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS evidence_needs JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||||
|
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS visual_needs JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||||
|
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS seo_notes JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||||
|
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS source_requirements JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||||
|
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS excluded_sources JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||||
|
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS tone TEXT",
|
||||||
|
"ALTER TABLE article_plans ADD COLUMN IF NOT EXISTS audience TEXT",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS plan_sections (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
article_plan_id UUID NOT NULL REFERENCES article_plans(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL,
|
||||||
|
heading TEXT NOT NULL,
|
||||||
|
purpose TEXT,
|
||||||
|
key_points JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
evidence_needs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
claims_to_support JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
target_word_count INTEGER,
|
||||||
|
UNIQUE (article_plan_id, sort_order)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"ALTER TABLE plan_sections ADD COLUMN IF NOT EXISTS key_points JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||||
|
"ALTER TABLE plan_sections ADD COLUMN IF NOT EXISTS evidence_needs JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS evidence_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
source_title TEXT NOT NULL,
|
||||||
|
source_url TEXT NOT NULL,
|
||||||
|
source_type TEXT NOT NULL,
|
||||||
|
source_quality_score NUMERIC(4, 3) NOT NULL,
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
supports_claims JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
artifact_manifest_id UUID,
|
||||||
|
retrieved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
review_status TEXT NOT NULL DEFAULT 'PENDING'
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"ALTER TABLE evidence_items ADD COLUMN IF NOT EXISTS review_status TEXT NOT NULL DEFAULT 'PENDING'",
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS claims (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
section_id UUID REFERENCES plan_sections(id) ON DELETE SET NULL,
|
||||||
|
claim_text TEXT NOT NULL,
|
||||||
|
support_status TEXT NOT NULL CHECK (
|
||||||
|
support_status IN ({CLAIM_SUPPORT_STATUS_VALUES})
|
||||||
|
),
|
||||||
|
risk_level TEXT NOT NULL CHECK (risk_level IN ({CLAIM_RISK_LEVEL_VALUES})),
|
||||||
|
evidence_item_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS article_drafts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
meta_title TEXT,
|
||||||
|
meta_description TEXT,
|
||||||
|
body_object_key TEXT,
|
||||||
|
body_markdown TEXT NOT NULL DEFAULT '',
|
||||||
|
faq_items JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
visual_placeholders JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
evidence_references JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
unsupported_claim_warnings JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
based_on_draft_id UUID REFERENCES article_drafts(id) ON DELETE SET NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ({ARTICLE_STATUS_VALUES})),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (article_id, version)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS body_markdown TEXT NOT NULL DEFAULT ''",
|
||||||
|
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS faq_items JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||||
|
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS visual_placeholders JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||||
|
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS evidence_references JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||||
|
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS unsupported_claim_warnings JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||||
|
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS based_on_draft_id UUID REFERENCES article_drafts(id) ON DELETE SET NULL",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS article_review_reports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
review_kind TEXT NOT NULL CHECK (review_kind IN ('SEO', 'LANGUAGE')),
|
||||||
|
draft_id UUID NOT NULL REFERENCES article_drafts(id) ON DELETE CASCADE,
|
||||||
|
score INTEGER NOT NULL,
|
||||||
|
issues JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
recommended_slug TEXT NOT NULL,
|
||||||
|
recommended_title TEXT NOT NULL,
|
||||||
|
schema_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
rules_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS article_review_suggestions (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
review_kind TEXT NOT NULL CHECK (review_kind IN ('SEO', 'LANGUAGE')),
|
||||||
|
report_id UUID NOT NULL REFERENCES article_review_reports(id) ON DELETE CASCADE,
|
||||||
|
suggestion_key TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL,
|
||||||
|
location TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
suggested_fix TEXT,
|
||||||
|
suggested_rewrite TEXT,
|
||||||
|
patch JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('PENDING', 'ACCEPTED', 'REJECTED', 'EDITED')),
|
||||||
|
applied_text TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (article_id, review_kind, suggestion_key)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS assets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
section_id UUID REFERENCES plan_sections(id) ON DELETE SET NULL,
|
||||||
|
asset_type TEXT NOT NULL CHECK (asset_type IN ({ASSET_TYPE_VALUES})),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
prompt TEXT,
|
||||||
|
object_key TEXT,
|
||||||
|
file_url TEXT,
|
||||||
|
alt_text TEXT,
|
||||||
|
caption TEXT,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ({ASSET_STATUS_VALUES})),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"ALTER TABLE assets ADD COLUMN IF NOT EXISTS section_id UUID REFERENCES plan_sections(id) ON DELETE SET NULL",
|
||||||
|
"ALTER TABLE assets ADD COLUMN IF NOT EXISTS object_key TEXT",
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_revisions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||||
|
revision_index INTEGER NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
actor_user_id UUID REFERENCES users(id),
|
||||||
|
payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS workflow_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
from_status TEXT CHECK (from_status IN ({ARTICLE_STATUS_VALUES})),
|
||||||
|
to_status TEXT CHECK (to_status IN ({ARTICLE_STATUS_VALUES})),
|
||||||
|
actor_user_id UUID REFERENCES users(id),
|
||||||
|
payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_jobs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
article_id UUID REFERENCES articles(id) ON DELETE SET NULL,
|
||||||
|
parent_job_id UUID REFERENCES agent_jobs(id) ON DELETE SET NULL,
|
||||||
|
attempt INTEGER NOT NULL DEFAULT 1,
|
||||||
|
job_type TEXT NOT NULL CHECK (job_type IN ({AGENT_JOB_TYPE_VALUES})),
|
||||||
|
agent_profile TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ({AGENT_JOB_STATUS_VALUES})),
|
||||||
|
workspace_path TEXT,
|
||||||
|
input_files JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
output_files JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
||||||
|
error_category TEXT CHECK (
|
||||||
|
error_category IS NULL
|
||||||
|
OR error_category IN ({AGENT_JOB_ERROR_CATEGORY_VALUES})
|
||||||
|
),
|
||||||
|
error_message TEXT,
|
||||||
|
stdout TEXT NOT NULL DEFAULT '',
|
||||||
|
stderr TEXT NOT NULL DEFAULT '',
|
||||||
|
exit_code INTEGER,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
queued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
finished_at TIMESTAMPTZ
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS parent_job_id UUID REFERENCES agent_jobs(id) ON DELETE SET NULL",
|
||||||
|
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS attempt INTEGER NOT NULL DEFAULT 1",
|
||||||
|
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS stdout TEXT NOT NULL DEFAULT ''",
|
||||||
|
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS stderr TEXT NOT NULL DEFAULT ''",
|
||||||
|
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS exit_code INTEGER",
|
||||||
|
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS duration_ms INTEGER",
|
||||||
|
"ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS payload JSONB NOT NULL DEFAULT '{}'::jsonb",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS research_run_manifests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
agent_job_id UUID NOT NULL REFERENCES agent_jobs(id) ON DELETE CASCADE,
|
||||||
|
s3_prefix TEXT NOT NULL,
|
||||||
|
source_urls JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
object_keys JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
content_hashes JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
artifact_types JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
metadata_references JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
artifacts JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS publish_commits (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
target_site_id UUID NOT NULL REFERENCES target_sites(id),
|
||||||
|
repository_url TEXT NOT NULL,
|
||||||
|
branch TEXT NOT NULL,
|
||||||
|
commit_sha TEXT,
|
||||||
|
content_bundle_manifest JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ({PUBLISHING_STATUS_VALUES})),
|
||||||
|
deployment_status TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS prompt_versions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
prompt_key TEXT NOT NULL,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ({SCRIPT_CONFIG_STATUS_VALUES})),
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
activated_at TIMESTAMPTZ,
|
||||||
|
diff JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
||||||
|
UNIQUE (prompt_key, version)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS script_config_version_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
target_site_id UUID NOT NULL REFERENCES target_sites(id) ON DELETE CASCADE,
|
||||||
|
version_id UUID NOT NULL REFERENCES script_config_versions(id) ON DELETE CASCADE,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
actor_user_id UUID REFERENCES users(id),
|
||||||
|
payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_target_sites_slug ON target_sites (slug)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_workflow_templates_slug ON workflow_templates (slug)",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workflow_templates_status_updated
|
||||||
|
ON workflow_templates (status, updated_at DESC)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workflow_template_stages_order
|
||||||
|
ON workflow_template_stages (workflow_template_id, position)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workflow_template_events_history
|
||||||
|
ON workflow_template_events (workflow_template_id, created_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_articles_dashboard
|
||||||
|
ON articles (target_site_id, status, publishing_status, updated_at DESC)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_articles_editor_dashboard
|
||||||
|
ON articles (assigned_editor_id, updated_at DESC)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_articles_workflow_template
|
||||||
|
ON articles (workflow_template_id, workflow_template_version)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_jobs_queue
|
||||||
|
ON agent_jobs (status, queued_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_jobs_article_timeline
|
||||||
|
ON agent_jobs (article_id, job_type, queued_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workflow_events_timeline
|
||||||
|
ON workflow_events (article_id, created_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asset_revisions_asset
|
||||||
|
ON asset_revisions (asset_id, revision_index, created_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_research_run_manifests_article
|
||||||
|
ON research_run_manifests (article_id, created_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_publish_commits_article
|
||||||
|
ON publish_commits (article_id, created_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_script_config_version_events_site
|
||||||
|
ON script_config_version_events (target_site_id, created_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_article_review_reports_article
|
||||||
|
ON article_review_reports (article_id, review_kind, created_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_article_review_suggestions_report
|
||||||
|
ON article_review_suggestions (report_id, status)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS target_sites (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
publishing_type TEXT NOT NULL DEFAULT 'git_next',
|
||||||
|
default_language TEXT NOT NULL DEFAULT 'en',
|
||||||
|
brand_voice TEXT NOT NULL,
|
||||||
|
audience TEXT NOT NULL,
|
||||||
|
seo_rules TEXT NOT NULL DEFAULT '{}',
|
||||||
|
visual_rules TEXT NOT NULL DEFAULT '{}',
|
||||||
|
source_rules TEXT NOT NULL DEFAULT '{}',
|
||||||
|
publishing_rules TEXT NOT NULL DEFAULT '{}',
|
||||||
|
active_script_config_version_id TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS script_config_versions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
target_site_id TEXT NOT NULL,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
diff TEXT NOT NULL DEFAULT '{}',
|
||||||
|
rollback_target_version_id TEXT,
|
||||||
|
activated_at TEXT,
|
||||||
|
publishing_yaml TEXT NOT NULL,
|
||||||
|
publishing_yaml_hash TEXT NOT NULL,
|
||||||
|
transform_script TEXT NOT NULL,
|
||||||
|
transform_script_hash TEXT NOT NULL,
|
||||||
|
UNIQUE (target_site_id, version)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS workflow_templates (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
updated_by TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
activated_at TEXT,
|
||||||
|
archived_at TEXT
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS workflow_template_stages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
workflow_template_id TEXT NOT NULL,
|
||||||
|
stable_key TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
owner_role TEXT NOT NULL,
|
||||||
|
runner_profile_key TEXT NOT NULL,
|
||||||
|
required_inputs TEXT NOT NULL DEFAULT '[]',
|
||||||
|
expected_outputs TEXT NOT NULL DEFAULT '[]',
|
||||||
|
acceptance_criteria TEXT NOT NULL DEFAULT '[]',
|
||||||
|
requires_human_approval INTEGER NOT NULL DEFAULT 0,
|
||||||
|
retry_policy TEXT NOT NULL DEFAULT '{}',
|
||||||
|
parts TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (workflow_template_id, stable_key)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS workflow_template_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
workflow_template_id TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
actor_user_id TEXT,
|
||||||
|
payload TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS articles (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
target_site_id TEXT NOT NULL,
|
||||||
|
workflow_template_id TEXT,
|
||||||
|
workflow_template_version INTEGER,
|
||||||
|
workflow_template_snapshot TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
publishing_status TEXT NOT NULL,
|
||||||
|
brief_description TEXT NOT NULL,
|
||||||
|
working_title TEXT,
|
||||||
|
language TEXT NOT NULL DEFAULT 'en',
|
||||||
|
content_type TEXT NOT NULL DEFAULT 'longform_guide',
|
||||||
|
primary_keyword TEXT,
|
||||||
|
assigned_editor_id TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS boundary_questions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_id TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL,
|
||||||
|
category TEXT NOT NULL DEFAULT 'general',
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
answer TEXT,
|
||||||
|
is_required INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (article_id, sort_order)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS article_plans (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_id TEXT NOT NULL,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
title_options TEXT NOT NULL DEFAULT '[]',
|
||||||
|
recommended_title TEXT,
|
||||||
|
reader_persona TEXT,
|
||||||
|
search_intent TEXT,
|
||||||
|
thesis TEXT,
|
||||||
|
claims_to_prove TEXT NOT NULL DEFAULT '[]',
|
||||||
|
evidence_needs TEXT NOT NULL DEFAULT '[]',
|
||||||
|
visual_needs TEXT NOT NULL DEFAULT '[]',
|
||||||
|
seo_notes TEXT NOT NULL DEFAULT '[]',
|
||||||
|
source_requirements TEXT NOT NULL DEFAULT '[]',
|
||||||
|
excluded_sources TEXT NOT NULL DEFAULT '[]',
|
||||||
|
tone TEXT,
|
||||||
|
audience TEXT,
|
||||||
|
risks TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (article_id, version)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS plan_sections (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_plan_id TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL,
|
||||||
|
heading TEXT NOT NULL,
|
||||||
|
purpose TEXT,
|
||||||
|
key_points TEXT NOT NULL DEFAULT '[]',
|
||||||
|
evidence_needs TEXT NOT NULL DEFAULT '[]',
|
||||||
|
claims_to_support TEXT NOT NULL DEFAULT '[]',
|
||||||
|
target_word_count INTEGER,
|
||||||
|
UNIQUE (article_plan_id, sort_order)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS evidence_items (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_id TEXT NOT NULL,
|
||||||
|
source_title TEXT NOT NULL,
|
||||||
|
source_url TEXT NOT NULL,
|
||||||
|
source_type TEXT NOT NULL,
|
||||||
|
source_quality_score REAL NOT NULL,
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
supports_claims TEXT NOT NULL DEFAULT '[]',
|
||||||
|
artifact_manifest_id TEXT,
|
||||||
|
retrieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
review_status TEXT NOT NULL DEFAULT 'PENDING'
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS claims (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_id TEXT NOT NULL,
|
||||||
|
section_id TEXT,
|
||||||
|
claim_text TEXT NOT NULL,
|
||||||
|
support_status TEXT NOT NULL,
|
||||||
|
risk_level TEXT NOT NULL,
|
||||||
|
evidence_item_ids TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS article_drafts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_id TEXT NOT NULL,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
meta_title TEXT,
|
||||||
|
meta_description TEXT,
|
||||||
|
body_object_key TEXT,
|
||||||
|
body_markdown TEXT NOT NULL DEFAULT '',
|
||||||
|
faq_items TEXT NOT NULL DEFAULT '[]',
|
||||||
|
visual_placeholders TEXT NOT NULL DEFAULT '[]',
|
||||||
|
evidence_references TEXT NOT NULL DEFAULT '[]',
|
||||||
|
unsupported_claim_warnings TEXT NOT NULL DEFAULT '[]',
|
||||||
|
based_on_draft_id TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (article_id, version)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS article_review_reports (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_id TEXT NOT NULL,
|
||||||
|
review_kind TEXT NOT NULL,
|
||||||
|
draft_id TEXT NOT NULL,
|
||||||
|
score INTEGER NOT NULL,
|
||||||
|
issues TEXT NOT NULL DEFAULT '[]',
|
||||||
|
recommended_slug TEXT NOT NULL,
|
||||||
|
recommended_title TEXT NOT NULL,
|
||||||
|
schema_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
rules_snapshot TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS article_review_suggestions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_id TEXT NOT NULL,
|
||||||
|
review_kind TEXT NOT NULL,
|
||||||
|
report_id TEXT NOT NULL,
|
||||||
|
suggestion_key TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL,
|
||||||
|
location TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
suggested_fix TEXT,
|
||||||
|
suggested_rewrite TEXT,
|
||||||
|
patch TEXT NOT NULL DEFAULT '{}',
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
applied_text TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (article_id, review_kind, suggestion_key)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS assets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_id TEXT NOT NULL,
|
||||||
|
section_id TEXT,
|
||||||
|
asset_type TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
prompt TEXT,
|
||||||
|
object_key TEXT,
|
||||||
|
file_url TEXT,
|
||||||
|
alt_text TEXT,
|
||||||
|
caption TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_revisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_id TEXT NOT NULL,
|
||||||
|
asset_id TEXT NOT NULL,
|
||||||
|
revision_index INTEGER NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
actor_user_id TEXT,
|
||||||
|
payload TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS workflow_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_id TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
from_status TEXT,
|
||||||
|
to_status TEXT,
|
||||||
|
actor_user_id TEXT,
|
||||||
|
payload TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_id TEXT,
|
||||||
|
parent_job_id TEXT,
|
||||||
|
attempt INTEGER NOT NULL DEFAULT 1,
|
||||||
|
job_type TEXT NOT NULL,
|
||||||
|
agent_profile TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
workspace_path TEXT,
|
||||||
|
input_files TEXT NOT NULL DEFAULT '[]',
|
||||||
|
output_files TEXT NOT NULL DEFAULT '[]',
|
||||||
|
payload TEXT NOT NULL DEFAULT '{}',
|
||||||
|
error_category TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
stdout TEXT NOT NULL DEFAULT '',
|
||||||
|
stderr TEXT NOT NULL DEFAULT '',
|
||||||
|
exit_code INTEGER,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
queued_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
started_at TEXT,
|
||||||
|
finished_at TEXT
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS research_run_manifests (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_id TEXT NOT NULL,
|
||||||
|
agent_job_id TEXT NOT NULL,
|
||||||
|
s3_prefix TEXT NOT NULL,
|
||||||
|
source_urls TEXT NOT NULL DEFAULT '[]',
|
||||||
|
object_keys TEXT NOT NULL DEFAULT '[]',
|
||||||
|
content_hashes TEXT NOT NULL DEFAULT '[]',
|
||||||
|
artifact_types TEXT NOT NULL DEFAULT '[]',
|
||||||
|
metadata_references TEXT NOT NULL DEFAULT '[]',
|
||||||
|
artifacts TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS publish_commits (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_id TEXT NOT NULL,
|
||||||
|
target_site_id TEXT NOT NULL,
|
||||||
|
repository_url TEXT NOT NULL,
|
||||||
|
branch TEXT NOT NULL,
|
||||||
|
commit_sha TEXT,
|
||||||
|
content_bundle_manifest TEXT NOT NULL DEFAULT '{}',
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
deployment_status TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS prompt_versions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
prompt_key TEXT NOT NULL,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
activated_at TEXT,
|
||||||
|
diff TEXT NOT NULL DEFAULT '{}',
|
||||||
|
UNIQUE (prompt_key, version)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS script_config_version_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
target_site_id TEXT NOT NULL,
|
||||||
|
version_id TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
actor_user_id TEXT,
|
||||||
|
payload TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_target_sites_slug ON target_sites (slug)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_workflow_templates_slug ON workflow_templates (slug)",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workflow_templates_status_updated
|
||||||
|
ON workflow_templates (status, updated_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workflow_template_stages_order
|
||||||
|
ON workflow_template_stages (workflow_template_id, position)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workflow_template_events_history
|
||||||
|
ON workflow_template_events (workflow_template_id, created_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_articles_dashboard
|
||||||
|
ON articles (target_site_id, status, publishing_status, updated_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_articles_editor_dashboard
|
||||||
|
ON articles (assigned_editor_id, updated_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_articles_workflow_template
|
||||||
|
ON articles (workflow_template_id, workflow_template_version)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_jobs_queue
|
||||||
|
ON agent_jobs (status, queued_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_jobs_article_timeline
|
||||||
|
ON agent_jobs (article_id, job_type, queued_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workflow_events_timeline
|
||||||
|
ON workflow_events (article_id, created_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asset_revisions_asset
|
||||||
|
ON asset_revisions (asset_id, revision_index, created_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_research_run_manifests_article
|
||||||
|
ON research_run_manifests (article_id, created_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_publish_commits_article
|
||||||
|
ON publish_commits (article_id, created_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_script_config_version_events_site
|
||||||
|
ON script_config_version_events (target_site_id, created_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_article_review_reports_article
|
||||||
|
ON article_review_reports (article_id, review_kind, created_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_article_review_suggestions_report
|
||||||
|
ON article_review_suggestions (report_id, status)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def schema_statements_for_dsn(dsn: str) -> tuple[str, ...]:
|
||||||
|
if dsn.startswith("sqlite:///"):
|
||||||
|
return SQLITE_SCHEMA_STATEMENTS
|
||||||
|
return POSTGRES_SCHEMA_STATEMENTS
|
||||||
|
|
||||||
|
|
||||||
|
def setup_database(dsn: str) -> None:
|
||||||
|
statements = schema_statements_for_dsn(dsn)
|
||||||
|
if dsn.startswith("sqlite:///"):
|
||||||
|
sqlite_path = dsn.removeprefix("sqlite:///")
|
||||||
|
with closing(sqlite3.connect(sqlite_path)) as connection:
|
||||||
|
for statement in statements:
|
||||||
|
connection.execute(statement)
|
||||||
|
connection.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
with psycopg.connect(dsn) as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
for statement in statements:
|
||||||
|
cursor.execute(statement)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from src.application.seed_data import seed_reference_data
|
||||||
|
from src.infrastructure.repositories import open_backend_repository
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
dsn = os.environ.get("DATABASE_URL") or os.environ.get("POSTGRES_DSN")
|
||||||
|
if not dsn:
|
||||||
|
raise RuntimeError("DATABASE_URL or POSTGRES_DSN must be set")
|
||||||
|
|
||||||
|
repository = open_backend_repository(dsn)
|
||||||
|
repository.setup()
|
||||||
|
seed_reference_data(repository)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, status
|
||||||
|
|
||||||
|
from src.application.auth import get_current_user_by_email
|
||||||
|
from src.application.seed_data import seed_reference_data
|
||||||
|
from src.domain.contracts import CurrentUser, Role
|
||||||
|
from src.infrastructure.repositories import BackendRepository, open_backend_repository
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
UNAUTHORIZED_DETAIL = "Unauthorized"
|
||||||
|
FORBIDDEN_DETAIL = "Forbidden"
|
||||||
|
|
||||||
|
_runtime_repository: BackendRepository | None = None
|
||||||
|
_runtime_repository_dsn: str | None = None
|
||||||
|
_runtime_temp_dir: tempfile.TemporaryDirectory[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_repository() -> BackendRepository:
|
||||||
|
global _runtime_repository
|
||||||
|
global _runtime_repository_dsn
|
||||||
|
global _runtime_temp_dir
|
||||||
|
|
||||||
|
dsn = (
|
||||||
|
os.environ.get("DATABASE_URL")
|
||||||
|
or os.environ.get("POSTGRES_DSN")
|
||||||
|
or os.environ.get("PIPELINE_TEST_DATABASE_DSN")
|
||||||
|
)
|
||||||
|
if dsn is None:
|
||||||
|
if _runtime_temp_dir is None:
|
||||||
|
_runtime_temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
sqlite_path = Path(_runtime_temp_dir.name) / "pipeline-demo-auth.db"
|
||||||
|
dsn = f"sqlite:///{sqlite_path}"
|
||||||
|
|
||||||
|
if _runtime_repository is not None and _runtime_repository_dsn == dsn:
|
||||||
|
return _runtime_repository
|
||||||
|
|
||||||
|
repository = open_backend_repository(dsn)
|
||||||
|
repository.setup()
|
||||||
|
seed_reference_data(repository)
|
||||||
|
_runtime_repository = repository
|
||||||
|
_runtime_repository_dsn = dsn
|
||||||
|
return repository
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
demo_user_email: str | None = Header(default=None, alias=DEMO_USER_EMAIL_HEADER),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> CurrentUser:
|
||||||
|
if not demo_user_email:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=UNAUTHORIZED_DETAIL,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return get_current_user_by_email(repository, demo_user_email)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=UNAUTHORIZED_DETAIL,
|
||||||
|
) from error
|
||||||
|
|
||||||
|
|
||||||
|
def require_roles(
|
||||||
|
allowed_roles: Iterable[Role],
|
||||||
|
) -> Callable[[CurrentUser], CurrentUser]:
|
||||||
|
allowed = frozenset(allowed_roles)
|
||||||
|
|
||||||
|
def dependency(
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> CurrentUser:
|
||||||
|
if current_user.role not in allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=FORBIDDEN_DETAIL,
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
return dependency
|
||||||
@@ -1,10 +1,60 @@
|
|||||||
from fastapi import FastAPI
|
import os
|
||||||
from fastapi.responses import JSONResponse
|
from typing import Any
|
||||||
|
|
||||||
from src.infrastructure.dependencies import check_dependencies
|
from fastapi import FastAPI
|
||||||
|
from fastapi.openapi.utils import get_openapi
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from src.domain.contracts.openapi import inject_contract_schemas
|
||||||
|
from src.presentation.routes.agent_jobs import (
|
||||||
|
internal_router as internal_agent_jobs_router,
|
||||||
|
)
|
||||||
|
from src.presentation.routes.agent_jobs import router as agent_jobs_router
|
||||||
|
from src.presentation.routes.articles import router as articles_router
|
||||||
|
from src.presentation.routes.assets import router as assets_router
|
||||||
|
from src.presentation.routes.auth import router as auth_router
|
||||||
|
from src.presentation.routes.boundary_questions import router as boundary_questions_router
|
||||||
|
from src.presentation.routes.drafts import router as drafts_router
|
||||||
|
from src.presentation.routes.evidence import router as evidence_router
|
||||||
|
from src.presentation.routes.final_approval import router as final_approval_router
|
||||||
|
from src.presentation.routes.plans import router as plans_router
|
||||||
|
from src.presentation.routes.publishing import router as publishing_router
|
||||||
|
from src.presentation.routes.reviews import router as reviews_router
|
||||||
|
from src.presentation.routes.sites import router as sites_router
|
||||||
|
from src.presentation.routes.workflow_templates import router as workflow_templates_router
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="AI Content Pipeline Backend")
|
app = FastAPI(title="AI Content Pipeline Backend")
|
||||||
|
_allowed_origins = [
|
||||||
|
origin.strip()
|
||||||
|
for origin in os.getenv(
|
||||||
|
"PIPELINE_CORS_ALLOW_ORIGINS",
|
||||||
|
"http://localhost:3000,http://localhost:13300",
|
||||||
|
).split(",")
|
||||||
|
if origin.strip()
|
||||||
|
]
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=_allowed_origins,
|
||||||
|
allow_credentials=False,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(articles_router)
|
||||||
|
app.include_router(assets_router)
|
||||||
|
app.include_router(boundary_questions_router)
|
||||||
|
app.include_router(plans_router)
|
||||||
|
app.include_router(evidence_router)
|
||||||
|
app.include_router(drafts_router)
|
||||||
|
app.include_router(reviews_router)
|
||||||
|
app.include_router(final_approval_router)
|
||||||
|
app.include_router(publishing_router)
|
||||||
|
app.include_router(agent_jobs_router)
|
||||||
|
app.include_router(internal_agent_jobs_router)
|
||||||
|
app.include_router(sites_router)
|
||||||
|
app.include_router(workflow_templates_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -14,6 +64,8 @@ def health() -> dict[str, str]:
|
|||||||
|
|
||||||
@app.get("/health/dependencies")
|
@app.get("/health/dependencies")
|
||||||
def dependency_health() -> JSONResponse:
|
def dependency_health() -> JSONResponse:
|
||||||
|
from src.infrastructure.dependencies import check_dependencies
|
||||||
|
|
||||||
dependencies = check_dependencies()
|
dependencies = check_dependencies()
|
||||||
is_ok = all(status == "ok" for status in dependencies.values())
|
is_ok = all(status == "ok" for status in dependencies.values())
|
||||||
|
|
||||||
@@ -25,3 +77,20 @@ def dependency_health() -> JSONResponse:
|
|||||||
"dependencies": dependencies,
|
"dependencies": dependencies,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def custom_openapi() -> dict[str, Any]:
|
||||||
|
if app.openapi_schema:
|
||||||
|
return app.openapi_schema
|
||||||
|
|
||||||
|
openapi_schema = get_openapi(
|
||||||
|
title=app.title,
|
||||||
|
version=app.version,
|
||||||
|
routes=app.routes,
|
||||||
|
)
|
||||||
|
inject_contract_schemas(openapi_schema)
|
||||||
|
app.openapi_schema = openapi_schema
|
||||||
|
return app.openapi_schema
|
||||||
|
|
||||||
|
|
||||||
|
app.openapi = custom_openapi
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Backend HTTP route modules."""
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, HTTPException, Response, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from src.application.agent_jobs import (
|
||||||
|
cancel_agent_job,
|
||||||
|
claim_next_agent_job,
|
||||||
|
complete_agent_job,
|
||||||
|
create_test_codex_job,
|
||||||
|
get_agent_job,
|
||||||
|
list_agent_jobs,
|
||||||
|
retry_agent_job,
|
||||||
|
)
|
||||||
|
from src.domain.auth import ADMIN_ROLES
|
||||||
|
from src.domain.contracts import (
|
||||||
|
AgentJobListResponse,
|
||||||
|
AgentJobResponse,
|
||||||
|
AgentJobTestCodexRequest,
|
||||||
|
CurrentUser,
|
||||||
|
)
|
||||||
|
from src.infrastructure.repositories import BackendRepository
|
||||||
|
from src.presentation.dependencies import get_repository, require_roles
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["agent-jobs"])
|
||||||
|
internal_router = APIRouter(prefix="/internal/agent-jobs", include_in_schema=False)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentJobCompletionRequest(BaseModel):
|
||||||
|
workspace_path: str | None = None
|
||||||
|
stdout: str = ""
|
||||||
|
stderr: str = ""
|
||||||
|
exit_code: int | None = None
|
||||||
|
duration_ms: int | None = Field(default=None, ge=0)
|
||||||
|
output: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/agent-jobs/test-codex",
|
||||||
|
response_model=AgentJobResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_test_codex_job(
|
||||||
|
request: AgentJobTestCodexRequest = Body(
|
||||||
|
default_factory=AgentJobTestCodexRequest
|
||||||
|
),
|
||||||
|
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> AgentJobResponse:
|
||||||
|
return create_test_codex_job(repository, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/agent-jobs", response_model=AgentJobListResponse)
|
||||||
|
def get_agent_jobs(
|
||||||
|
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> AgentJobListResponse:
|
||||||
|
return list_agent_jobs(repository)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/agent-jobs/{job_id}", response_model=AgentJobResponse)
|
||||||
|
def get_agent_job_route(
|
||||||
|
job_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> AgentJobResponse:
|
||||||
|
try:
|
||||||
|
return get_agent_job(repository, job_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/agent-jobs/{job_id}/retry",
|
||||||
|
response_model=AgentJobResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_retry_agent_job(
|
||||||
|
job_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> AgentJobResponse:
|
||||||
|
try:
|
||||||
|
return retry_agent_job(
|
||||||
|
repository,
|
||||||
|
job_id,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except PermissionError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/agent-jobs/{job_id}/cancel", response_model=AgentJobResponse)
|
||||||
|
def post_cancel_agent_job(
|
||||||
|
job_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> AgentJobResponse:
|
||||||
|
try:
|
||||||
|
return cancel_agent_job(
|
||||||
|
repository,
|
||||||
|
job_id,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@internal_router.post("/claim", response_model=None)
|
||||||
|
def post_claim_agent_job(
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
):
|
||||||
|
claimed = claim_next_agent_job(repository)
|
||||||
|
if claimed is None:
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
return claimed
|
||||||
|
|
||||||
|
|
||||||
|
@internal_router.post("/{job_id}/complete", response_model=AgentJobResponse)
|
||||||
|
def post_complete_agent_job(
|
||||||
|
job_id: UUID,
|
||||||
|
request: AgentJobCompletionRequest,
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> AgentJobResponse:
|
||||||
|
try:
|
||||||
|
return complete_agent_job(
|
||||||
|
repository,
|
||||||
|
job_id=job_id,
|
||||||
|
workspace_path=request.workspace_path,
|
||||||
|
stdout=request.stdout,
|
||||||
|
stderr=request.stderr,
|
||||||
|
exit_code=request.exit_code,
|
||||||
|
duration_ms=request.duration_ms,
|
||||||
|
output=request.output,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from src.application.articles import (
|
||||||
|
create_article,
|
||||||
|
get_article_detail,
|
||||||
|
list_articles,
|
||||||
|
)
|
||||||
|
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
||||||
|
from src.domain.contracts import (
|
||||||
|
ArticleCreateRequest,
|
||||||
|
ArticleCreateResponse,
|
||||||
|
ArticleDetailResponse,
|
||||||
|
ArticleListResponse,
|
||||||
|
CurrentUser,
|
||||||
|
)
|
||||||
|
from src.infrastructure.repositories import BackendRepository
|
||||||
|
from src.presentation.dependencies import get_repository, require_roles
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["articles"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles",
|
||||||
|
response_model=ArticleCreateResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_article(
|
||||||
|
request: ArticleCreateRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ArticleCreateResponse:
|
||||||
|
try:
|
||||||
|
return create_article(
|
||||||
|
repository,
|
||||||
|
request=request,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid target_site_id",
|
||||||
|
) from error
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(error),
|
||||||
|
) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/articles", response_model=ArticleListResponse)
|
||||||
|
def list_articles_route(
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ArticleListResponse:
|
||||||
|
return list_articles(repository)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/articles/{article_id}",
|
||||||
|
response_model=ArticleDetailResponse,
|
||||||
|
)
|
||||||
|
def get_article(
|
||||||
|
article_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ArticleDetailResponse:
|
||||||
|
try:
|
||||||
|
return get_article_detail(
|
||||||
|
repository=repository,
|
||||||
|
article_id=article_id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Not Found",
|
||||||
|
) from error
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from src.application.assets import (
|
||||||
|
approve_asset,
|
||||||
|
generate_asset_specs,
|
||||||
|
list_assets,
|
||||||
|
update_asset,
|
||||||
|
upload_asset_file,
|
||||||
|
)
|
||||||
|
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
||||||
|
from src.domain.contracts import (
|
||||||
|
AssetGenerateSpecsResponse,
|
||||||
|
AssetListResponse,
|
||||||
|
AssetResponse,
|
||||||
|
AssetUpdateRequest,
|
||||||
|
AssetUploadRequest,
|
||||||
|
AssetUploadResponse,
|
||||||
|
CurrentUser,
|
||||||
|
)
|
||||||
|
from src.infrastructure.object_storage import open_object_storage_client
|
||||||
|
from src.infrastructure.repositories import BackendRepository
|
||||||
|
from src.presentation.dependencies import get_repository, require_roles
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["assets"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/assets/generate-specs",
|
||||||
|
response_model=AssetGenerateSpecsResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_generate_asset_specs(
|
||||||
|
article_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> AssetGenerateSpecsResponse:
|
||||||
|
try:
|
||||||
|
return generate_asset_specs(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except PermissionError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/articles/{article_id}/assets",
|
||||||
|
response_model=AssetListResponse,
|
||||||
|
)
|
||||||
|
def get_article_assets(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> AssetListResponse:
|
||||||
|
try:
|
||||||
|
return list_assets(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/articles/{article_id}/assets/{asset_id}",
|
||||||
|
response_model=AssetResponse,
|
||||||
|
)
|
||||||
|
def patch_article_asset(
|
||||||
|
article_id: UUID,
|
||||||
|
asset_id: UUID,
|
||||||
|
request: AssetUpdateRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> AssetResponse:
|
||||||
|
try:
|
||||||
|
return update_asset(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
request=request,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/assets/{asset_id}/approve",
|
||||||
|
response_model=AssetResponse,
|
||||||
|
)
|
||||||
|
def post_approve_asset(
|
||||||
|
article_id: UUID,
|
||||||
|
asset_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> AssetResponse:
|
||||||
|
try:
|
||||||
|
return approve_asset(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/assets/{asset_id}/upload",
|
||||||
|
response_model=AssetUploadResponse,
|
||||||
|
)
|
||||||
|
def post_upload_asset_file(
|
||||||
|
article_id: UUID,
|
||||||
|
asset_id: UUID,
|
||||||
|
request: AssetUploadRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> AssetUploadResponse:
|
||||||
|
try:
|
||||||
|
payload = base64.b64decode(request.content_base64.encode("utf-8"), validate=True)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Invalid base64 payload",
|
||||||
|
) from error
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Uploaded file is empty",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return upload_asset_file(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
filename=request.filename,
|
||||||
|
content_type=request.content_type,
|
||||||
|
content=payload,
|
||||||
|
current_user=current_user,
|
||||||
|
object_storage=open_object_storage_client(),
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from src.domain.contracts import CurrentUser, CurrentUserResponse
|
||||||
|
from src.presentation.dependencies import get_current_user
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=CurrentUserResponse)
|
||||||
|
def get_me(
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> CurrentUserResponse:
|
||||||
|
return CurrentUserResponse(user=current_user)
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from src.application.boundary_questions import (
|
||||||
|
create_plan_generation_job,
|
||||||
|
generate_boundary_questions,
|
||||||
|
list_boundary_questions,
|
||||||
|
submit_boundary_questions,
|
||||||
|
update_boundary_question,
|
||||||
|
)
|
||||||
|
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
||||||
|
from src.domain.contracts import (
|
||||||
|
AgentJobListResponse,
|
||||||
|
ArticleCreateResponse,
|
||||||
|
BoundaryQuestionListResponse,
|
||||||
|
BoundaryQuestionResponse,
|
||||||
|
BoundaryQuestionUpdateRequest,
|
||||||
|
CurrentUser,
|
||||||
|
)
|
||||||
|
from src.infrastructure.repositories import BackendRepository
|
||||||
|
from src.presentation.dependencies import get_repository, require_roles
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["boundary-questions"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/boundary-questions/generate",
|
||||||
|
response_model=BoundaryQuestionListResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_generate_boundary_questions(
|
||||||
|
article_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> BoundaryQuestionListResponse:
|
||||||
|
try:
|
||||||
|
return generate_boundary_questions(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Not Found",
|
||||||
|
) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/articles/{article_id}/boundary-questions",
|
||||||
|
response_model=BoundaryQuestionListResponse,
|
||||||
|
)
|
||||||
|
def get_boundary_questions(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> BoundaryQuestionListResponse:
|
||||||
|
try:
|
||||||
|
return list_boundary_questions(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Not Found",
|
||||||
|
) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/articles/{article_id}/boundary-questions/{question_id}",
|
||||||
|
response_model=BoundaryQuestionResponse,
|
||||||
|
)
|
||||||
|
def patch_boundary_question(
|
||||||
|
article_id: UUID,
|
||||||
|
question_id: UUID,
|
||||||
|
request: BoundaryQuestionUpdateRequest,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> BoundaryQuestionResponse:
|
||||||
|
try:
|
||||||
|
return update_boundary_question(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
question_id=question_id,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Not Found",
|
||||||
|
) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/boundary-questions/submit",
|
||||||
|
response_model=ArticleCreateResponse,
|
||||||
|
)
|
||||||
|
def post_submit_boundary_questions(
|
||||||
|
article_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ArticleCreateResponse:
|
||||||
|
try:
|
||||||
|
article = submit_boundary_questions(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Not Found",
|
||||||
|
) from error
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=str(error),
|
||||||
|
) from error
|
||||||
|
|
||||||
|
return ArticleCreateResponse(article=article)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/plans/generate",
|
||||||
|
response_model=AgentJobListResponse,
|
||||||
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
)
|
||||||
|
def post_generate_plan(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> AgentJobListResponse:
|
||||||
|
try:
|
||||||
|
return create_plan_generation_job(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Not Found",
|
||||||
|
) from error
|
||||||
|
except PermissionError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=str(error),
|
||||||
|
) from error
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from src.application.drafts import assemble_draft, get_draft, list_drafts, update_draft
|
||||||
|
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
||||||
|
from src.domain.contracts import (
|
||||||
|
CurrentUser,
|
||||||
|
DraftAssembleResponse,
|
||||||
|
DraftListResponse,
|
||||||
|
DraftResponse,
|
||||||
|
DraftUpdateRequest,
|
||||||
|
)
|
||||||
|
from src.infrastructure.repositories import BackendRepository
|
||||||
|
from src.presentation.dependencies import get_repository, require_roles
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["drafts"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/draft/assemble",
|
||||||
|
response_model=DraftAssembleResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_assemble_draft(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> DraftAssembleResponse:
|
||||||
|
try:
|
||||||
|
return assemble_draft(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except (PermissionError, ValueError) as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/articles/{article_id}/drafts",
|
||||||
|
response_model=DraftListResponse,
|
||||||
|
)
|
||||||
|
def get_article_drafts(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> DraftListResponse:
|
||||||
|
try:
|
||||||
|
return list_drafts(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/articles/{article_id}/drafts/{draft_id}",
|
||||||
|
response_model=DraftResponse,
|
||||||
|
)
|
||||||
|
def get_article_draft(
|
||||||
|
article_id: UUID,
|
||||||
|
draft_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> DraftResponse:
|
||||||
|
try:
|
||||||
|
return get_draft(repository, article_id=article_id, draft_id=draft_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/articles/{article_id}/drafts/{draft_id}",
|
||||||
|
response_model=DraftResponse,
|
||||||
|
)
|
||||||
|
def patch_article_draft(
|
||||||
|
article_id: UUID,
|
||||||
|
draft_id: UUID,
|
||||||
|
request: DraftUpdateRequest,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> DraftResponse:
|
||||||
|
try:
|
||||||
|
return update_draft(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
draft_id=draft_id,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from src.application.evidence import (
|
||||||
|
add_evidence,
|
||||||
|
approve_final,
|
||||||
|
get_evidence_matrix,
|
||||||
|
remove_evidence,
|
||||||
|
start_draft,
|
||||||
|
update_evidence,
|
||||||
|
)
|
||||||
|
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
||||||
|
from src.domain.contracts import (
|
||||||
|
AgentJobListResponse,
|
||||||
|
ArticleCreateResponse,
|
||||||
|
CurrentUser,
|
||||||
|
EvidenceMatrixResponse,
|
||||||
|
EvidenceResponse,
|
||||||
|
EvidenceCreateRequest,
|
||||||
|
EvidenceUpdateRequest,
|
||||||
|
)
|
||||||
|
from src.infrastructure.repositories import BackendRepository
|
||||||
|
from src.presentation.dependencies import get_repository, require_roles
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["evidence"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/articles/{article_id}/evidence",
|
||||||
|
response_model=EvidenceMatrixResponse,
|
||||||
|
)
|
||||||
|
def get_article_evidence(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> EvidenceMatrixResponse:
|
||||||
|
try:
|
||||||
|
return get_evidence_matrix(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/articles/{article_id}/evidence/{evidence_id}",
|
||||||
|
response_model=EvidenceResponse,
|
||||||
|
)
|
||||||
|
def patch_evidence(
|
||||||
|
article_id: UUID,
|
||||||
|
evidence_id: UUID,
|
||||||
|
request: EvidenceUpdateRequest,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> EvidenceResponse:
|
||||||
|
try:
|
||||||
|
return update_evidence(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
evidence_id=evidence_id,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/evidence",
|
||||||
|
response_model=EvidenceResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_evidence(
|
||||||
|
article_id: UUID,
|
||||||
|
request: EvidenceCreateRequest,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> EvidenceResponse:
|
||||||
|
try:
|
||||||
|
return add_evidence(repository, article_id=article_id, request=request)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/articles/{article_id}/evidence/{evidence_id}",
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
def delete_evidence(
|
||||||
|
article_id: UUID,
|
||||||
|
evidence_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
remove_evidence(repository, article_id=article_id, evidence_id=evidence_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/draft/start",
|
||||||
|
response_model=AgentJobListResponse,
|
||||||
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
)
|
||||||
|
def post_start_draft(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> AgentJobListResponse:
|
||||||
|
try:
|
||||||
|
return start_draft(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except PermissionError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/final/approve",
|
||||||
|
response_model=ArticleCreateResponse,
|
||||||
|
)
|
||||||
|
def post_final_approve(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ArticleCreateResponse:
|
||||||
|
try:
|
||||||
|
return approve_final(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except PermissionError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from src.application.final_approval import (
|
||||||
|
approve_final_article,
|
||||||
|
get_final_approval_checklist,
|
||||||
|
request_final_revision,
|
||||||
|
)
|
||||||
|
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
||||||
|
from src.domain.contracts import (
|
||||||
|
CurrentUser,
|
||||||
|
FinalApprovalChecklistResponse,
|
||||||
|
FinalApprovalRequest,
|
||||||
|
FinalApprovalResponse,
|
||||||
|
FinalRevisionRequest,
|
||||||
|
FinalRevisionRequestResponse,
|
||||||
|
)
|
||||||
|
from src.infrastructure.repositories import BackendRepository
|
||||||
|
from src.presentation.dependencies import get_repository, require_roles
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["final-approval"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/articles/{article_id}/final-approval/checklist",
|
||||||
|
response_model=FinalApprovalChecklistResponse,
|
||||||
|
)
|
||||||
|
def get_article_final_approval_checklist(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> FinalApprovalChecklistResponse:
|
||||||
|
try:
|
||||||
|
return get_final_approval_checklist(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
request=None,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/final-approval",
|
||||||
|
response_model=FinalApprovalResponse,
|
||||||
|
)
|
||||||
|
def post_article_final_approval(
|
||||||
|
article_id: UUID,
|
||||||
|
request: FinalApprovalRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> FinalApprovalResponse:
|
||||||
|
try:
|
||||||
|
return approve_final_article(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
request=request,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except PermissionError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/final-revision-request",
|
||||||
|
response_model=FinalRevisionRequestResponse,
|
||||||
|
)
|
||||||
|
def post_article_final_revision_request(
|
||||||
|
article_id: UUID,
|
||||||
|
request: FinalRevisionRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> FinalRevisionRequestResponse:
|
||||||
|
try:
|
||||||
|
return request_final_revision(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
request=request,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from src.application.plans import (
|
||||||
|
approve_plan,
|
||||||
|
edit_plan,
|
||||||
|
generate_plan,
|
||||||
|
get_plan,
|
||||||
|
list_plans,
|
||||||
|
request_plan_revision,
|
||||||
|
)
|
||||||
|
from src.application.research import list_research_runs, start_research_run
|
||||||
|
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
||||||
|
from src.domain.contracts import (
|
||||||
|
CurrentUser,
|
||||||
|
PlanListResponse,
|
||||||
|
PlanResponse,
|
||||||
|
PlanRevisionRequest,
|
||||||
|
PlanUpdateRequest,
|
||||||
|
ResearchListResponse,
|
||||||
|
ResearchStartResponse,
|
||||||
|
ReviewActionResponse,
|
||||||
|
)
|
||||||
|
from src.infrastructure.object_storage import open_object_storage_client
|
||||||
|
from src.infrastructure.repositories import BackendRepository
|
||||||
|
from src.presentation.dependencies import get_repository, require_roles
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["plans"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/plan/generate",
|
||||||
|
response_model=PlanResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_generate_plan(
|
||||||
|
article_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> PlanResponse:
|
||||||
|
try:
|
||||||
|
return generate_plan(repository, article_id=article_id, current_user=current_user)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except PermissionError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/articles/{article_id}/plans", response_model=PlanListResponse)
|
||||||
|
def get_plans(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> PlanListResponse:
|
||||||
|
try:
|
||||||
|
return list_plans(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/articles/{article_id}/plans/{plan_id}", response_model=PlanResponse)
|
||||||
|
def get_plan_route(
|
||||||
|
article_id: UUID,
|
||||||
|
plan_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> PlanResponse:
|
||||||
|
try:
|
||||||
|
return get_plan(repository, article_id=article_id, plan_id=plan_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/articles/{article_id}/plans/{plan_id}",
|
||||||
|
response_model=PlanResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def patch_plan(
|
||||||
|
article_id: UUID,
|
||||||
|
plan_id: UUID,
|
||||||
|
request: PlanUpdateRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> PlanResponse:
|
||||||
|
try:
|
||||||
|
return edit_plan(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
plan_id=plan_id,
|
||||||
|
request=request,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/plans/{plan_id}/approve",
|
||||||
|
response_model=ReviewActionResponse,
|
||||||
|
)
|
||||||
|
def post_approve_plan(
|
||||||
|
article_id: UUID,
|
||||||
|
plan_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ReviewActionResponse:
|
||||||
|
try:
|
||||||
|
return approve_plan(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
plan_id=plan_id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/plans/{plan_id}/request-revision",
|
||||||
|
response_model=ReviewActionResponse,
|
||||||
|
)
|
||||||
|
def post_request_plan_revision(
|
||||||
|
article_id: UUID,
|
||||||
|
plan_id: UUID,
|
||||||
|
request: PlanRevisionRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ReviewActionResponse:
|
||||||
|
try:
|
||||||
|
return request_plan_revision(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
plan_id=plan_id,
|
||||||
|
request=request,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/research/start",
|
||||||
|
response_model=ResearchStartResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_start_research(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ResearchStartResponse:
|
||||||
|
try:
|
||||||
|
return start_research_run(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
object_storage=open_object_storage_client(),
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except PermissionError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/articles/{article_id}/research",
|
||||||
|
response_model=ResearchListResponse,
|
||||||
|
)
|
||||||
|
def get_research(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ResearchListResponse:
|
||||||
|
try:
|
||||||
|
return list_research_runs(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from src.application.publishing import (
|
||||||
|
create_publish_commit,
|
||||||
|
get_publishing_status,
|
||||||
|
list_publish_commits,
|
||||||
|
run_publishing_dry_run,
|
||||||
|
)
|
||||||
|
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
||||||
|
from src.domain.contracts import (
|
||||||
|
CurrentUser,
|
||||||
|
PublishCommitCreateResponse,
|
||||||
|
PublishCommitListResponse,
|
||||||
|
PublishingDryRunResponse,
|
||||||
|
PublishingStatusResponse,
|
||||||
|
)
|
||||||
|
from src.infrastructure.repositories import BackendRepository
|
||||||
|
from src.presentation.dependencies import get_repository, require_roles
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["publishing"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/publishing/dry-run",
|
||||||
|
response_model=PublishingDryRunResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_publishing_dry_run(
|
||||||
|
article_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> PublishingDryRunResponse:
|
||||||
|
try:
|
||||||
|
return run_publishing_dry_run(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except (PermissionError, ValueError, RuntimeError) as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/publishing/create-commit",
|
||||||
|
response_model=PublishCommitCreateResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_publishing_create_commit(
|
||||||
|
article_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> PublishCommitCreateResponse:
|
||||||
|
try:
|
||||||
|
return create_publish_commit(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except (PermissionError, ValueError, RuntimeError) as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/articles/{article_id}/publishing/status",
|
||||||
|
response_model=PublishingStatusResponse,
|
||||||
|
)
|
||||||
|
def get_article_publishing_status(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> PublishingStatusResponse:
|
||||||
|
try:
|
||||||
|
return get_publishing_status(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/articles/{article_id}/publishing/commits",
|
||||||
|
response_model=PublishCommitListResponse,
|
||||||
|
)
|
||||||
|
def get_article_publishing_commits(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> PublishCommitListResponse:
|
||||||
|
try:
|
||||||
|
return list_publish_commits(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from src.application.reviews import (
|
||||||
|
accept_suggestion,
|
||||||
|
edit_suggestion,
|
||||||
|
get_final_review_issues,
|
||||||
|
get_language_report,
|
||||||
|
get_seo_report,
|
||||||
|
reject_suggestion,
|
||||||
|
run_language_review,
|
||||||
|
run_seo_review,
|
||||||
|
)
|
||||||
|
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
||||||
|
from src.domain.contracts import (
|
||||||
|
ContentReviewKind,
|
||||||
|
CurrentUser,
|
||||||
|
FinalReviewIssuesResponse,
|
||||||
|
LanguageReviewReportResponse,
|
||||||
|
LanguageReviewRunResponse,
|
||||||
|
ReviewSuggestionActionResponse,
|
||||||
|
ReviewSuggestionEditRequest,
|
||||||
|
SeoReviewReportResponse,
|
||||||
|
SeoReviewRunResponse,
|
||||||
|
)
|
||||||
|
from src.infrastructure.repositories import BackendRepository
|
||||||
|
from src.presentation.dependencies import get_repository, require_roles
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["reviews"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/seo/review",
|
||||||
|
response_model=SeoReviewRunResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_run_seo_review(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> SeoReviewRunResponse:
|
||||||
|
try:
|
||||||
|
return run_seo_review(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except PermissionError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/articles/{article_id}/seo/report",
|
||||||
|
response_model=SeoReviewReportResponse,
|
||||||
|
)
|
||||||
|
def get_article_seo_report(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> SeoReviewReportResponse:
|
||||||
|
try:
|
||||||
|
return get_seo_report(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/language/review",
|
||||||
|
response_model=LanguageReviewRunResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_run_language_review(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> LanguageReviewRunResponse:
|
||||||
|
try:
|
||||||
|
return run_language_review(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except PermissionError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/articles/{article_id}/language/report",
|
||||||
|
response_model=LanguageReviewReportResponse,
|
||||||
|
)
|
||||||
|
def get_article_language_report(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> LanguageReviewReportResponse:
|
||||||
|
try:
|
||||||
|
return get_language_report(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/seo/suggestions/{suggestion_id}/accept",
|
||||||
|
response_model=ReviewSuggestionActionResponse,
|
||||||
|
)
|
||||||
|
def post_accept_seo_suggestion(
|
||||||
|
article_id: UUID,
|
||||||
|
suggestion_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ReviewSuggestionActionResponse:
|
||||||
|
try:
|
||||||
|
return accept_suggestion(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=ContentReviewKind.SEO,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/seo/suggestions/{suggestion_id}/reject",
|
||||||
|
response_model=ReviewSuggestionActionResponse,
|
||||||
|
)
|
||||||
|
def post_reject_seo_suggestion(
|
||||||
|
article_id: UUID,
|
||||||
|
suggestion_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ReviewSuggestionActionResponse:
|
||||||
|
try:
|
||||||
|
return reject_suggestion(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=ContentReviewKind.SEO,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/seo/suggestions/{suggestion_id}/edit",
|
||||||
|
response_model=ReviewSuggestionActionResponse,
|
||||||
|
)
|
||||||
|
def post_edit_seo_suggestion(
|
||||||
|
article_id: UUID,
|
||||||
|
suggestion_id: UUID,
|
||||||
|
request: ReviewSuggestionEditRequest,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ReviewSuggestionActionResponse:
|
||||||
|
try:
|
||||||
|
return edit_suggestion(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=ContentReviewKind.SEO,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/language/suggestions/{suggestion_id}/accept",
|
||||||
|
response_model=ReviewSuggestionActionResponse,
|
||||||
|
)
|
||||||
|
def post_accept_language_suggestion(
|
||||||
|
article_id: UUID,
|
||||||
|
suggestion_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ReviewSuggestionActionResponse:
|
||||||
|
try:
|
||||||
|
return accept_suggestion(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=ContentReviewKind.LANGUAGE,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/language/suggestions/{suggestion_id}/reject",
|
||||||
|
response_model=ReviewSuggestionActionResponse,
|
||||||
|
)
|
||||||
|
def post_reject_language_suggestion(
|
||||||
|
article_id: UUID,
|
||||||
|
suggestion_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ReviewSuggestionActionResponse:
|
||||||
|
try:
|
||||||
|
return reject_suggestion(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=ContentReviewKind.LANGUAGE,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/language/suggestions/{suggestion_id}/edit",
|
||||||
|
response_model=ReviewSuggestionActionResponse,
|
||||||
|
)
|
||||||
|
def post_edit_language_suggestion(
|
||||||
|
article_id: UUID,
|
||||||
|
suggestion_id: UUID,
|
||||||
|
request: ReviewSuggestionEditRequest,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ReviewSuggestionActionResponse:
|
||||||
|
try:
|
||||||
|
return edit_suggestion(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=ContentReviewKind.LANGUAGE,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/articles/{article_id}/final-review/issues",
|
||||||
|
response_model=FinalReviewIssuesResponse,
|
||||||
|
)
|
||||||
|
def get_article_final_review_issues(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> FinalReviewIssuesResponse:
|
||||||
|
try:
|
||||||
|
return get_final_review_issues(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from src.application.site_config import (
|
||||||
|
activate_script_config_version,
|
||||||
|
create_script_config_version,
|
||||||
|
create_target_site,
|
||||||
|
get_target_site,
|
||||||
|
list_script_config_audit_events,
|
||||||
|
list_script_config_versions,
|
||||||
|
rollback_script_config_version,
|
||||||
|
update_target_site,
|
||||||
|
)
|
||||||
|
from src.domain.auth import ADMIN_ROLES, EDITOR_OR_ADMIN_ROLES
|
||||||
|
from src.domain.contracts import (
|
||||||
|
CurrentUser,
|
||||||
|
ScriptConfigVersionAuditEventListResponse,
|
||||||
|
ScriptConfigVersionCreateRequest,
|
||||||
|
ScriptConfigVersionListResponse,
|
||||||
|
ScriptConfigVersionResponse,
|
||||||
|
TargetSiteConfigCreateRequest,
|
||||||
|
TargetSiteConfigResponse,
|
||||||
|
TargetSiteConfigUpdateRequest,
|
||||||
|
)
|
||||||
|
from src.infrastructure.repositories import BackendRepository
|
||||||
|
from src.presentation.dependencies import get_repository, require_roles
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["sites"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sites", response_model=list[TargetSiteConfigResponse])
|
||||||
|
def list_sites(
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> list[TargetSiteConfigResponse]:
|
||||||
|
return [TargetSiteConfigResponse(site=site) for site in repository.target_sites.list()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sites/{site_id}", response_model=TargetSiteConfigResponse)
|
||||||
|
def get_site(
|
||||||
|
site_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> TargetSiteConfigResponse:
|
||||||
|
try:
|
||||||
|
return get_target_site(repository, site_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sites",
|
||||||
|
response_model=TargetSiteConfigResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_site(
|
||||||
|
request: TargetSiteConfigCreateRequest,
|
||||||
|
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> TargetSiteConfigResponse:
|
||||||
|
return create_target_site(repository, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/sites/{site_id}", response_model=TargetSiteConfigResponse)
|
||||||
|
def patch_site(
|
||||||
|
site_id: UUID,
|
||||||
|
request: TargetSiteConfigUpdateRequest,
|
||||||
|
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> TargetSiteConfigResponse:
|
||||||
|
try:
|
||||||
|
return update_target_site(repository, site_id, request)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sites/{site_id}/publishing-config/versions",
|
||||||
|
response_model=ScriptConfigVersionListResponse,
|
||||||
|
)
|
||||||
|
def get_script_config_versions(
|
||||||
|
site_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ScriptConfigVersionListResponse:
|
||||||
|
try:
|
||||||
|
versions = list_script_config_versions(repository, site_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
return ScriptConfigVersionListResponse(versions=versions)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sites/{site_id}/publishing-config/audit",
|
||||||
|
response_model=ScriptConfigVersionAuditEventListResponse,
|
||||||
|
)
|
||||||
|
def get_script_config_audit_events(
|
||||||
|
site_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ScriptConfigVersionAuditEventListResponse:
|
||||||
|
try:
|
||||||
|
return list_script_config_audit_events(repository, site_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sites/{site_id}/publishing-config/versions",
|
||||||
|
response_model=ScriptConfigVersionResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_script_config_version(
|
||||||
|
site_id: UUID,
|
||||||
|
request: ScriptConfigVersionCreateRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ScriptConfigVersionResponse:
|
||||||
|
try:
|
||||||
|
return create_script_config_version(
|
||||||
|
repository,
|
||||||
|
site_id=site_id,
|
||||||
|
current_user=current_user,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sites/{site_id}/publishing-config/versions/{version_id}/activate",
|
||||||
|
response_model=ScriptConfigVersionResponse,
|
||||||
|
)
|
||||||
|
def post_activate_script_config_version(
|
||||||
|
site_id: UUID,
|
||||||
|
version_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ScriptConfigVersionResponse:
|
||||||
|
try:
|
||||||
|
return activate_script_config_version(
|
||||||
|
repository,
|
||||||
|
site_id=site_id,
|
||||||
|
version_id=version_id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sites/{site_id}/publishing-config/versions/{version_id}/rollback",
|
||||||
|
response_model=ScriptConfigVersionResponse,
|
||||||
|
)
|
||||||
|
def post_rollback_script_config_version(
|
||||||
|
site_id: UUID,
|
||||||
|
version_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ScriptConfigVersionResponse:
|
||||||
|
try:
|
||||||
|
return rollback_script_config_version(
|
||||||
|
repository,
|
||||||
|
site_id=site_id,
|
||||||
|
version_id=version_id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from src.application.workflow_templates import (
|
||||||
|
activate_workflow_template,
|
||||||
|
add_workflow_stage,
|
||||||
|
archive_workflow_template,
|
||||||
|
create_workflow_template,
|
||||||
|
delete_workflow_stage,
|
||||||
|
get_workflow_template,
|
||||||
|
list_workflow_template_audit_events,
|
||||||
|
list_workflow_templates,
|
||||||
|
reorder_workflow_stages,
|
||||||
|
update_workflow_stage,
|
||||||
|
update_workflow_template,
|
||||||
|
)
|
||||||
|
from src.domain.auth import ADMIN_ROLES, EDITOR_OR_ADMIN_ROLES
|
||||||
|
from src.domain.contracts import (
|
||||||
|
CurrentUser,
|
||||||
|
WorkflowStageCreateRequest,
|
||||||
|
WorkflowStageReorderRequest,
|
||||||
|
WorkflowStageResponse,
|
||||||
|
WorkflowStageUpdateRequest,
|
||||||
|
WorkflowTemplateAuditEventListResponse,
|
||||||
|
WorkflowTemplateCreateRequest,
|
||||||
|
WorkflowTemplateListResponse,
|
||||||
|
WorkflowTemplateResponse,
|
||||||
|
WorkflowTemplateUpdateRequest,
|
||||||
|
)
|
||||||
|
from src.infrastructure.repositories import BackendRepository
|
||||||
|
from src.presentation.dependencies import get_repository, require_roles
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["workflow_templates"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/admin/workflows",
|
||||||
|
response_model=WorkflowTemplateResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_workflow_template(
|
||||||
|
request: WorkflowTemplateCreateRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
return create_workflow_template(
|
||||||
|
repository,
|
||||||
|
request=request,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/workflows", response_model=WorkflowTemplateListResponse)
|
||||||
|
def get_workflow_templates(
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowTemplateListResponse:
|
||||||
|
return list_workflow_templates(repository, current_user=current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/admin/workflows/{workflow_id}",
|
||||||
|
response_model=WorkflowTemplateResponse,
|
||||||
|
)
|
||||||
|
def get_workflow_template_detail(
|
||||||
|
workflow_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
try:
|
||||||
|
return get_workflow_template(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/admin/workflows/{workflow_id}",
|
||||||
|
response_model=WorkflowTemplateResponse,
|
||||||
|
)
|
||||||
|
def patch_workflow_template(
|
||||||
|
workflow_id: UUID,
|
||||||
|
request: WorkflowTemplateUpdateRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
try:
|
||||||
|
return update_workflow_template(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
request=request,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/admin/workflows/{workflow_id}/stages",
|
||||||
|
response_model=WorkflowStageResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_workflow_stage(
|
||||||
|
workflow_id: UUID,
|
||||||
|
request: WorkflowStageCreateRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowStageResponse:
|
||||||
|
try:
|
||||||
|
return add_workflow_stage(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
request=request,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/admin/workflows/{workflow_id}/stages/{stage_id}",
|
||||||
|
response_model=WorkflowStageResponse,
|
||||||
|
)
|
||||||
|
def patch_workflow_stage(
|
||||||
|
workflow_id: UUID,
|
||||||
|
stage_id: UUID,
|
||||||
|
request: WorkflowStageUpdateRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowStageResponse:
|
||||||
|
try:
|
||||||
|
return update_workflow_stage(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
stage_id=stage_id,
|
||||||
|
request=request,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/admin/workflows/{workflow_id}/stages/{stage_id}",
|
||||||
|
response_model=WorkflowTemplateResponse,
|
||||||
|
)
|
||||||
|
def delete_workflow_template_stage(
|
||||||
|
workflow_id: UUID,
|
||||||
|
stage_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
try:
|
||||||
|
return delete_workflow_stage(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
stage_id=stage_id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/admin/workflows/{workflow_id}/stages/reorder",
|
||||||
|
response_model=WorkflowTemplateResponse,
|
||||||
|
)
|
||||||
|
def post_reorder_workflow_stages(
|
||||||
|
workflow_id: UUID,
|
||||||
|
request: WorkflowStageReorderRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
try:
|
||||||
|
return reorder_workflow_stages(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
stage_ids=request.stage_ids,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/admin/workflows/{workflow_id}/activate",
|
||||||
|
response_model=WorkflowTemplateResponse,
|
||||||
|
)
|
||||||
|
def post_activate_workflow_template(
|
||||||
|
workflow_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
try:
|
||||||
|
return activate_workflow_template(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/admin/workflows/{workflow_id}/archive",
|
||||||
|
response_model=WorkflowTemplateResponse,
|
||||||
|
)
|
||||||
|
def post_archive_workflow_template(
|
||||||
|
workflow_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowTemplateResponse:
|
||||||
|
try:
|
||||||
|
return archive_workflow_template(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/admin/workflows/{workflow_id}/audit",
|
||||||
|
response_model=WorkflowTemplateAuditEventListResponse,
|
||||||
|
)
|
||||||
|
def get_workflow_template_audit_events(
|
||||||
|
workflow_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> WorkflowTemplateAuditEventListResponse:
|
||||||
|
try:
|
||||||
|
return list_workflow_template_audit_events(
|
||||||
|
repository,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
@@ -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,391 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||||
|
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||||
|
from src.presentation.dependencies import get_repository # noqa: E402
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_ADMIN_EMAIL = "admin@example.com"
|
||||||
|
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class AdminWorkflowTemplatesPublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'workflow-templates.db'}"
|
||||||
|
self.repository = open_backend_repository(dsn)
|
||||||
|
self.repository.setup()
|
||||||
|
seed_reference_data(self.repository)
|
||||||
|
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
self.tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_admin_creates_workflow_template_with_two_editable_ordered_stages_and_structured_parts(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
admin_headers = {DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}
|
||||||
|
|
||||||
|
create_response = self.client.post(
|
||||||
|
"/api/admin/workflows",
|
||||||
|
headers=admin_headers,
|
||||||
|
json=self._workflow_payload("article-production-v1"),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||||
|
workflow = create_response.json()["workflow"]
|
||||||
|
self.assertEqual("article-production-v1", workflow["slug"])
|
||||||
|
self.assertEqual("DRAFT", workflow["status"])
|
||||||
|
self.assertEqual(1, workflow["version"])
|
||||||
|
self.assertEqual([], workflow["stages"])
|
||||||
|
self.assertTrue(workflow["created_at"])
|
||||||
|
self.assertTrue(workflow["updated_at"])
|
||||||
|
|
||||||
|
intake_response = self.client.post(
|
||||||
|
f"/api/admin/workflows/{workflow['id']}/stages",
|
||||||
|
headers=admin_headers,
|
||||||
|
json=self._intake_stage_payload(),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, intake_response.status_code, intake_response.text)
|
||||||
|
intake_stage = intake_response.json()["stage"]
|
||||||
|
|
||||||
|
draft_response = self.client.post(
|
||||||
|
f"/api/admin/workflows/{workflow['id']}/stages",
|
||||||
|
headers=admin_headers,
|
||||||
|
json=self._draft_stage_payload(),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, draft_response.status_code, draft_response.text)
|
||||||
|
draft_stage = draft_response.json()["stage"]
|
||||||
|
|
||||||
|
edited_draft_parts = [
|
||||||
|
{
|
||||||
|
"key": "draft-outline",
|
||||||
|
"type": "outline",
|
||||||
|
"title": "Edited draft outline",
|
||||||
|
"payload": {
|
||||||
|
"prompt": "Build a sourced outline before drafting.",
|
||||||
|
"config": {
|
||||||
|
"minimum_sections": 5,
|
||||||
|
"require_source_placeholders": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Every section has a purpose.",
|
||||||
|
"Claims that need evidence are marked.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "draft-body",
|
||||||
|
"type": "generation",
|
||||||
|
"title": "Draft body",
|
||||||
|
"payload": {
|
||||||
|
"prompt": "Generate a complete longform draft.",
|
||||||
|
"config": {
|
||||||
|
"tone": "practical",
|
||||||
|
"include_evidence_markers": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Draft includes all approved outline sections.",
|
||||||
|
"Unsupported claims remain marked for review.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/admin/workflows/{workflow['id']}/stages/{draft_stage['id']}",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={
|
||||||
|
"display_name": "Draft and evidence assembly",
|
||||||
|
"description": "Create an evidence-aware article draft.",
|
||||||
|
"owner_role": "EDITOR",
|
||||||
|
"runner_profile_key": "draft-writer-v2",
|
||||||
|
"required_inputs": [
|
||||||
|
"approved_plan",
|
||||||
|
"evidence_matrix",
|
||||||
|
],
|
||||||
|
"expected_outputs": [
|
||||||
|
"article_draft",
|
||||||
|
"claim_evidence_map",
|
||||||
|
],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Draft follows the approved plan.",
|
||||||
|
"Evidence markers are preserved.",
|
||||||
|
],
|
||||||
|
"requires_human_approval": True,
|
||||||
|
"retry_policy": {
|
||||||
|
"max_attempts": 2,
|
||||||
|
"backoff_seconds": 120,
|
||||||
|
},
|
||||||
|
"parts": edited_draft_parts,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
|
||||||
|
detail_response = self.client.get(
|
||||||
|
f"/api/admin/workflows/{workflow['id']}",
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(200, detail_response.status_code, detail_response.text)
|
||||||
|
detail = detail_response.json()["workflow"]
|
||||||
|
stages = detail["stages"]
|
||||||
|
|
||||||
|
self.assertEqual([1, 2], [stage["position"] for stage in stages])
|
||||||
|
self.assertEqual(
|
||||||
|
["intake-boundary-questions", "draft-assembly"],
|
||||||
|
[stage["stable_key"] for stage in stages],
|
||||||
|
)
|
||||||
|
self.assertEqual(intake_stage["id"], stages[0]["id"])
|
||||||
|
self.assertEqual(draft_stage["id"], stages[1]["id"])
|
||||||
|
self.assertEqual(self._intake_stage_payload()["parts"], stages[0]["parts"])
|
||||||
|
self.assertEqual("Draft and evidence assembly", stages[1]["display_name"])
|
||||||
|
self.assertEqual("draft-writer-v2", stages[1]["runner_profile_key"])
|
||||||
|
self.assertTrue(stages[1]["requires_human_approval"])
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"max_attempts": 2,
|
||||||
|
"backoff_seconds": 120,
|
||||||
|
},
|
||||||
|
stages[1]["retry_policy"],
|
||||||
|
)
|
||||||
|
self.assertEqual(edited_draft_parts, stages[1]["parts"])
|
||||||
|
|
||||||
|
def test_editor_mutation_is_denied_with_existing_authorization_error_shape(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/admin/workflows",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json=self._workflow_payload("editor-denied-workflow"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(403, response.status_code, response.text)
|
||||||
|
self.assertEqual({"detail": "Forbidden"}, response.json())
|
||||||
|
|
||||||
|
def test_admin_reorders_activates_archives_and_audits_editor_visible_workflow(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
admin_headers = {DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}
|
||||||
|
editor_headers = {DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}
|
||||||
|
workflow = self.client.post(
|
||||||
|
"/api/admin/workflows",
|
||||||
|
headers=admin_headers,
|
||||||
|
json=self._workflow_payload("article-production-ops"),
|
||||||
|
).json()["workflow"]
|
||||||
|
workflow_id = workflow["id"]
|
||||||
|
intake_stage = self.client.post(
|
||||||
|
f"/api/admin/workflows/{workflow_id}/stages",
|
||||||
|
headers=admin_headers,
|
||||||
|
json=self._intake_stage_payload(),
|
||||||
|
).json()["stage"]
|
||||||
|
draft_stage = self.client.post(
|
||||||
|
f"/api/admin/workflows/{workflow_id}/stages",
|
||||||
|
headers=admin_headers,
|
||||||
|
json=self._draft_stage_payload(),
|
||||||
|
).json()["stage"]
|
||||||
|
|
||||||
|
reorder_response = self.client.post(
|
||||||
|
f"/api/admin/workflows/{workflow_id}/stages/reorder",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"stage_ids": [draft_stage["id"], intake_stage["id"]]},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, reorder_response.status_code, reorder_response.text)
|
||||||
|
self.assertEqual(
|
||||||
|
["draft-assembly", "intake-boundary-questions"],
|
||||||
|
[
|
||||||
|
stage["stable_key"]
|
||||||
|
for stage in reorder_response.json()["workflow"]["stages"]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
activate_response = self.client.post(
|
||||||
|
f"/api/admin/workflows/{workflow_id}/activate",
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(200, activate_response.status_code, activate_response.text)
|
||||||
|
activated = activate_response.json()["workflow"]
|
||||||
|
self.assertEqual("ACTIVE", activated["status"])
|
||||||
|
self.assertEqual(2, activated["version"])
|
||||||
|
|
||||||
|
editor_list_response = self.client.get(
|
||||||
|
"/api/admin/workflows",
|
||||||
|
headers=editor_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(200, editor_list_response.status_code, editor_list_response.text)
|
||||||
|
self.assertIn(
|
||||||
|
workflow_id,
|
||||||
|
[item["id"] for item in editor_list_response.json()["workflows"]],
|
||||||
|
)
|
||||||
|
editor_detail_response = self.client.get(
|
||||||
|
f"/api/admin/workflows/{workflow_id}",
|
||||||
|
headers=editor_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(200, editor_detail_response.status_code, editor_detail_response.text)
|
||||||
|
|
||||||
|
immutable_patch_response = self.client.patch(
|
||||||
|
f"/api/admin/workflows/{workflow_id}/stages/{draft_stage['id']}",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"display_name": "Should not mutate active template"},
|
||||||
|
)
|
||||||
|
self.assertEqual(409, immutable_patch_response.status_code, immutable_patch_response.text)
|
||||||
|
|
||||||
|
archive_response = self.client.post(
|
||||||
|
f"/api/admin/workflows/{workflow_id}/archive",
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(200, archive_response.status_code, archive_response.text)
|
||||||
|
self.assertEqual("ARCHIVED", archive_response.json()["workflow"]["status"])
|
||||||
|
|
||||||
|
archived_editor_list_response = self.client.get(
|
||||||
|
"/api/admin/workflows",
|
||||||
|
headers=editor_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
200,
|
||||||
|
archived_editor_list_response.status_code,
|
||||||
|
archived_editor_list_response.text,
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
workflow_id,
|
||||||
|
[item["id"] for item in archived_editor_list_response.json()["workflows"]],
|
||||||
|
)
|
||||||
|
archived_editor_detail_response = self.client.get(
|
||||||
|
f"/api/admin/workflows/{workflow_id}",
|
||||||
|
headers=editor_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
404,
|
||||||
|
archived_editor_detail_response.status_code,
|
||||||
|
archived_editor_detail_response.text,
|
||||||
|
)
|
||||||
|
|
||||||
|
audit_response = self.client.get(
|
||||||
|
f"/api/admin/workflows/{workflow_id}/audit",
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(200, audit_response.status_code, audit_response.text)
|
||||||
|
event_types = {
|
||||||
|
event["event_type"] for event in audit_response.json()["events"]
|
||||||
|
}
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"WORKFLOW_TEMPLATE_CREATED",
|
||||||
|
"WORKFLOW_STAGE_CREATED",
|
||||||
|
"WORKFLOW_STAGES_REORDERED",
|
||||||
|
"WORKFLOW_TEMPLATE_ACTIVATED",
|
||||||
|
"WORKFLOW_TEMPLATE_ARCHIVED",
|
||||||
|
}.issubset(event_types)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _workflow_payload(self, slug: str) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"name": "Article production workflow",
|
||||||
|
"slug": slug,
|
||||||
|
"description": "Reusable editorial workflow for longform article production.",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _intake_stage_payload(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"stable_key": "intake-boundary-questions",
|
||||||
|
"display_name": "Boundary question intake",
|
||||||
|
"description": "Collect required editorial context before planning.",
|
||||||
|
"position": 1,
|
||||||
|
"owner_role": "EDITOR",
|
||||||
|
"runner_profile_key": "boundary-question-agent-v1",
|
||||||
|
"required_inputs": [
|
||||||
|
"article_brief",
|
||||||
|
"target_site",
|
||||||
|
],
|
||||||
|
"expected_outputs": [
|
||||||
|
"answered_boundary_questions",
|
||||||
|
],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"All required boundary questions are answered.",
|
||||||
|
"Answers are tied to the source brief.",
|
||||||
|
],
|
||||||
|
"requires_human_approval": True,
|
||||||
|
"retry_policy": {
|
||||||
|
"max_attempts": 1,
|
||||||
|
"backoff_seconds": 0,
|
||||||
|
},
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
"key": "required-context-checklist",
|
||||||
|
"type": "checklist",
|
||||||
|
"title": "Required context checklist",
|
||||||
|
"payload": {
|
||||||
|
"prompt": "Identify missing audience, keyword, and source constraints.",
|
||||||
|
"config": {
|
||||||
|
"required_fields": [
|
||||||
|
"audience",
|
||||||
|
"primary_keyword",
|
||||||
|
"source_rules",
|
||||||
|
],
|
||||||
|
"block_on_missing": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Missing required context is listed explicitly.",
|
||||||
|
"No free-text parsing is needed to read the checklist.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _draft_stage_payload(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"stable_key": "draft-assembly",
|
||||||
|
"display_name": "Draft assembly",
|
||||||
|
"description": "Generate a first draft from the approved plan.",
|
||||||
|
"position": 2,
|
||||||
|
"owner_role": "EDITOR",
|
||||||
|
"runner_profile_key": "draft-writer-v1",
|
||||||
|
"required_inputs": [
|
||||||
|
"approved_plan",
|
||||||
|
],
|
||||||
|
"expected_outputs": [
|
||||||
|
"article_draft",
|
||||||
|
],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Draft follows the approved plan.",
|
||||||
|
],
|
||||||
|
"requires_human_approval": False,
|
||||||
|
"retry_policy": {
|
||||||
|
"max_attempts": 2,
|
||||||
|
"backoff_seconds": 60,
|
||||||
|
},
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
"key": "draft-outline",
|
||||||
|
"type": "outline",
|
||||||
|
"title": "Draft outline",
|
||||||
|
"payload": {
|
||||||
|
"prompt": "Build a concise outline before drafting.",
|
||||||
|
"config": {
|
||||||
|
"minimum_sections": 4,
|
||||||
|
"require_source_placeholders": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Every section has a purpose.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||||
|
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||||
|
from src.presentation.dependencies import get_repository # noqa: E402
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_ADMIN_EMAIL = "admin@example.com"
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentJobQueuePublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.workspace_root = Path(self.tmp_dir.name) / "runner-workspaces"
|
||||||
|
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'agent-jobs.db'}"
|
||||||
|
self.repository = open_backend_repository(dsn)
|
||||||
|
self.repository.setup()
|
||||||
|
seed_reference_data(self.repository)
|
||||||
|
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
self.tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_admin_enqueues_and_fake_runner_completes_test_codex_job(self) -> None:
|
||||||
|
create_response = self.client.post(
|
||||||
|
"/api/agent-jobs/test-codex",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||||
|
created = create_response.json()["job"]
|
||||||
|
self.assertEqual("TEST_CODEX", created["job_type"])
|
||||||
|
self.assertEqual("QUEUED", created["status"])
|
||||||
|
self.assertEqual(1, created["attempt"])
|
||||||
|
|
||||||
|
self._run_fake_runner_once()
|
||||||
|
|
||||||
|
get_response = self.client.get(
|
||||||
|
f"/api/agent-jobs/{created['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
list_response = self.client.get(
|
||||||
|
"/api/agent-jobs",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, get_response.status_code, get_response.text)
|
||||||
|
completed = get_response.json()["job"]
|
||||||
|
self.assertEqual("SUCCEEDED", completed["status"])
|
||||||
|
self.assertTrue(completed["workspace_path"])
|
||||||
|
self.assertIn("fake Codex runner completed", completed["stdout"])
|
||||||
|
self.assertEqual("", completed["stderr"])
|
||||||
|
self.assertEqual(0, completed["exit_code"])
|
||||||
|
self.assertGreaterEqual(completed["duration_ms"], 0)
|
||||||
|
self.assertEqual([{"path": "outputs/result.json", "content_hash": None}], completed["output_files"])
|
||||||
|
|
||||||
|
self.assertEqual(200, list_response.status_code, list_response.text)
|
||||||
|
self.assertIn(
|
||||||
|
created["id"],
|
||||||
|
[job["id"] for job in list_response.json()["jobs"]],
|
||||||
|
)
|
||||||
|
self.assertTrue((Path(completed["workspace_path"]) / "outputs" / "result.json").is_file())
|
||||||
|
|
||||||
|
def test_retry_creates_traceable_child_attempt(self) -> None:
|
||||||
|
failed = self.client.post(
|
||||||
|
"/api/agent-jobs/test-codex",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json={"fake_result": "invalid_schema"},
|
||||||
|
).json()["job"]
|
||||||
|
|
||||||
|
self._run_fake_runner_once()
|
||||||
|
|
||||||
|
failed_response = self.client.get(
|
||||||
|
f"/api/agent-jobs/{failed['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual("FAILED", failed_response.json()["job"]["status"])
|
||||||
|
self.assertEqual(
|
||||||
|
"FAILED_SCHEMA_VALIDATION",
|
||||||
|
failed_response.json()["job"]["error_category"],
|
||||||
|
)
|
||||||
|
|
||||||
|
retry_response = self.client.post(
|
||||||
|
f"/api/agent-jobs/{failed['id']}/retry",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(201, retry_response.status_code, retry_response.text)
|
||||||
|
retry = retry_response.json()["job"]
|
||||||
|
self.assertEqual("QUEUED", retry["status"])
|
||||||
|
self.assertEqual(failed["id"], retry["parent_job_id"])
|
||||||
|
self.assertEqual(2, retry["attempt"])
|
||||||
|
|
||||||
|
def test_cancelled_job_is_not_claimed_or_completed_by_runner(self) -> None:
|
||||||
|
created = self.client.post(
|
||||||
|
"/api/agent-jobs/test-codex",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
).json()["job"]
|
||||||
|
|
||||||
|
cancel_response = self.client.post(
|
||||||
|
f"/api/agent-jobs/{created['id']}/cancel",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, cancel_response.status_code, cancel_response.text)
|
||||||
|
self.assertEqual("CANCELLED", cancel_response.json()["job"]["status"])
|
||||||
|
|
||||||
|
self._run_fake_runner_once()
|
||||||
|
|
||||||
|
get_response = self.client.get(
|
||||||
|
f"/api/agent-jobs/{created['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
job = get_response.json()["job"]
|
||||||
|
self.assertEqual("CANCELLED", job["status"])
|
||||||
|
self.assertEqual([], job["output_files"])
|
||||||
|
self.assertIsNone(job["workspace_path"])
|
||||||
|
|
||||||
|
def _run_fake_runner_once(self) -> None:
|
||||||
|
claim_response = self.client.post("/internal/agent-jobs/claim")
|
||||||
|
if claim_response.status_code == 204:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.assertEqual(200, claim_response.status_code, claim_response.text)
|
||||||
|
job = claim_response.json()["job"]
|
||||||
|
workspace_path = self._write_fake_workspace(job)
|
||||||
|
fake_output: dict[str, Any]
|
||||||
|
if job["agent_profile"] == "fake-invalid-schema":
|
||||||
|
fake_output = {"status": "NOT_A_STATUS"}
|
||||||
|
else:
|
||||||
|
fake_output = {
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": "outputs/result.json"}],
|
||||||
|
"payload": {"job_id": job["id"], "mode": "fake"},
|
||||||
|
}
|
||||||
|
|
||||||
|
complete_response = self.client.post(
|
||||||
|
f"/internal/agent-jobs/{job['id']}/complete",
|
||||||
|
json={
|
||||||
|
"workspace_path": str(workspace_path),
|
||||||
|
"stdout": "fake Codex runner completed\n",
|
||||||
|
"stderr": "",
|
||||||
|
"exit_code": 0,
|
||||||
|
"duration_ms": 1,
|
||||||
|
"output": fake_output,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, complete_response.status_code, complete_response.text)
|
||||||
|
|
||||||
|
def _write_fake_workspace(self, job: dict[str, Any]) -> Path:
|
||||||
|
workspace_path = self.workspace_root / job["id"]
|
||||||
|
if workspace_path.exists():
|
||||||
|
shutil.rmtree(workspace_path)
|
||||||
|
(workspace_path / "inputs").mkdir(parents=True)
|
||||||
|
(workspace_path / "logs").mkdir()
|
||||||
|
(workspace_path / "outputs").mkdir()
|
||||||
|
(workspace_path / "inputs" / "job.json").write_text(str(job), encoding="utf-8")
|
||||||
|
(workspace_path / "logs" / "stdout.log").write_text(
|
||||||
|
"fake Codex runner completed\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(workspace_path / "logs" / "stderr.log").write_text("", encoding="utf-8")
|
||||||
|
(workspace_path / "outputs" / "result.json").write_text(
|
||||||
|
'{"status":"ok"}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return workspace_path
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleIntakePublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def test_editor_creates_article_and_reads_dashboard_detail_timeline(self) -> None:
|
||||||
|
headers = {DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}
|
||||||
|
|
||||||
|
editor_response = self.client.get("/api/me", headers=headers)
|
||||||
|
self.assertEqual(200, editor_response.status_code, editor_response.text)
|
||||||
|
editor_user_id = editor_response.json()["user"]["id"]
|
||||||
|
|
||||||
|
sites_response = self.client.get("/api/sites", headers=headers)
|
||||||
|
self.assertEqual(200, sites_response.status_code, sites_response.text)
|
||||||
|
target_site = sites_response.json()[0]["site"]
|
||||||
|
|
||||||
|
brief_description = (
|
||||||
|
"Create an editorial control guide for AI content intake "
|
||||||
|
f"{uuid4()}."
|
||||||
|
)
|
||||||
|
create_response = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"target_site_id": target_site["id"],
|
||||||
|
"brief_description": brief_description,
|
||||||
|
"working_title": "AI Content Intake Controls",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "AI content intake",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||||
|
created_article = create_response.json()["article"]
|
||||||
|
self.assertEqual("ARTICLE_BRIEF_CREATED", created_article["status"])
|
||||||
|
self.assertEqual(target_site["id"], created_article["target_site_id"])
|
||||||
|
self.assertEqual(brief_description, created_article["brief_description"])
|
||||||
|
|
||||||
|
article_id = created_article["id"]
|
||||||
|
list_response = self.client.get("/api/articles", headers=headers)
|
||||||
|
detail_response = self.client.get(f"/api/articles/{article_id}", headers=headers)
|
||||||
|
|
||||||
|
failures: list[str] = []
|
||||||
|
if list_response.status_code != 200:
|
||||||
|
failures.append(
|
||||||
|
"GET /api/articles should return the persisted dashboard list; "
|
||||||
|
f"got {list_response.status_code}: {list_response.text}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
article = self._find_article(list_response.json(), article_id)
|
||||||
|
if article is None:
|
||||||
|
failures.append(
|
||||||
|
"Created article is missing from GET /api/articles dashboard list"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._expect_equal(
|
||||||
|
failures,
|
||||||
|
"dashboard article status",
|
||||||
|
"ARTICLE_BRIEF_CREATED",
|
||||||
|
article.get("status"),
|
||||||
|
)
|
||||||
|
self._expect_equal(
|
||||||
|
failures,
|
||||||
|
"dashboard article target_site_id",
|
||||||
|
target_site["id"],
|
||||||
|
article.get("target_site_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if detail_response.status_code != 200:
|
||||||
|
failures.append(
|
||||||
|
"GET /api/articles/{article_id} should return the persisted detail "
|
||||||
|
f"shell; got {detail_response.status_code}: {detail_response.text}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
detail = detail_response.json()
|
||||||
|
if not isinstance(detail, dict):
|
||||||
|
failures.append("Article detail should return a JSON object")
|
||||||
|
detail = {}
|
||||||
|
|
||||||
|
detail_article = detail.get("article", {})
|
||||||
|
self._expect_equal(
|
||||||
|
failures,
|
||||||
|
"detail article id",
|
||||||
|
article_id,
|
||||||
|
detail_article.get("id"),
|
||||||
|
)
|
||||||
|
self._expect_equal(
|
||||||
|
failures,
|
||||||
|
"detail article status",
|
||||||
|
"ARTICLE_BRIEF_CREATED",
|
||||||
|
detail_article.get("status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
detail_target_site = detail.get("target_site")
|
||||||
|
if not isinstance(detail_target_site, dict):
|
||||||
|
failures.append("Article detail should include target_site summary")
|
||||||
|
else:
|
||||||
|
self._expect_equal(
|
||||||
|
failures,
|
||||||
|
"detail target_site id",
|
||||||
|
target_site["id"],
|
||||||
|
detail_target_site.get("id"),
|
||||||
|
)
|
||||||
|
self._expect_equal(
|
||||||
|
failures,
|
||||||
|
"detail target_site slug",
|
||||||
|
target_site["slug"],
|
||||||
|
detail_target_site.get("slug"),
|
||||||
|
)
|
||||||
|
|
||||||
|
workflow_events = detail.get("workflow_events")
|
||||||
|
if not isinstance(workflow_events, list):
|
||||||
|
failures.append("Article detail should include workflow_events timeline")
|
||||||
|
else:
|
||||||
|
created_events = [
|
||||||
|
event
|
||||||
|
for event in workflow_events
|
||||||
|
if event.get("event_type") == "ARTICLE_CREATED"
|
||||||
|
]
|
||||||
|
if not created_events:
|
||||||
|
failures.append(
|
||||||
|
"Article detail workflow_events should contain ARTICLE_CREATED"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
created_event = created_events[0]
|
||||||
|
self._expect_equal(
|
||||||
|
failures,
|
||||||
|
"ARTICLE_CREATED article_id",
|
||||||
|
article_id,
|
||||||
|
created_event.get("article_id"),
|
||||||
|
)
|
||||||
|
self._expect_equal(
|
||||||
|
failures,
|
||||||
|
"ARTICLE_CREATED to_status",
|
||||||
|
"ARTICLE_BRIEF_CREATED",
|
||||||
|
created_event.get("to_status"),
|
||||||
|
)
|
||||||
|
self._expect_equal(
|
||||||
|
failures,
|
||||||
|
"ARTICLE_CREATED actor_user_id",
|
||||||
|
editor_user_id,
|
||||||
|
created_event.get("actor_user_id"),
|
||||||
|
)
|
||||||
|
if not created_event.get("created_at"):
|
||||||
|
failures.append("ARTICLE_CREATED should include created_at")
|
||||||
|
|
||||||
|
self.assertEqual([], failures)
|
||||||
|
|
||||||
|
def test_editor_cannot_create_article_with_unknown_target_site(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"target_site_id": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"brief_description": "Intake target-site guardrail check.",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "integration",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(400, response.status_code, response.text)
|
||||||
|
self.assertEqual("Invalid target_site_id", response.json()["detail"])
|
||||||
|
|
||||||
|
def _find_article(
|
||||||
|
self,
|
||||||
|
response_body: Any,
|
||||||
|
article_id: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if not isinstance(response_body, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
articles = response_body.get("articles")
|
||||||
|
if not isinstance(articles, list):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
article
|
||||||
|
for article in articles
|
||||||
|
if isinstance(article, dict) and article.get("id") == article_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _expect_equal(
|
||||||
|
self,
|
||||||
|
failures: list[str],
|
||||||
|
label: str,
|
||||||
|
expected: object,
|
||||||
|
actual: object,
|
||||||
|
) -> None:
|
||||||
|
if actual != expected:
|
||||||
|
failures.append(f"{label}: expected {expected!r}, got {actual!r}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||||
|
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||||
|
from src.presentation.dependencies import get_repository # noqa: E402
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class AssetsMediaLibraryPublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.objects_root = Path(self.tmp_dir.name) / "objects"
|
||||||
|
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(self.objects_root)
|
||||||
|
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'assets-media-library.db'}"
|
||||||
|
self.repository = open_backend_repository(dsn)
|
||||||
|
self.repository.setup()
|
||||||
|
seed_reference_data(self.repository)
|
||||||
|
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
|
||||||
|
self.tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_generate_specs_links_assets_to_approved_plan_sections(self) -> None:
|
||||||
|
article_id, plan = self._prepare_article_with_approved_plan()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/generate-specs",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, response.status_code, response.text)
|
||||||
|
assets = response.json()["assets"]
|
||||||
|
self.assertEqual(len(plan["sections"]), len(assets))
|
||||||
|
section_ids = {section["id"] for section in plan["sections"]}
|
||||||
|
for asset in assets:
|
||||||
|
self.assertIn(asset["section_id"], section_ids)
|
||||||
|
self.assertEqual("GENERATED", asset["status"])
|
||||||
|
self.assertTrue(asset["title"])
|
||||||
|
|
||||||
|
def test_upload_stores_object_and_persists_object_key(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_assembled_draft()
|
||||||
|
asset_id = self._generate_specs(article_id)[0]["id"]
|
||||||
|
|
||||||
|
upload_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/{asset_id}/upload",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"filename": "hero.png",
|
||||||
|
"content_base64": base64.b64encode(b"fake-binary-image-content").decode("utf-8"),
|
||||||
|
"content_type": "image/png",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, upload_response.status_code, upload_response.text)
|
||||||
|
asset = upload_response.json()["asset"]
|
||||||
|
self.assertEqual("PENDING", asset["status"])
|
||||||
|
self.assertTrue(asset["object_key"])
|
||||||
|
self.assertTrue(asset["file_url"].startswith("file://"))
|
||||||
|
self.assertTrue(
|
||||||
|
(self.objects_root / asset["object_key"]).exists(),
|
||||||
|
"Uploaded object must exist in local object storage",
|
||||||
|
)
|
||||||
|
persisted = (self.objects_root / asset["object_key"]).read_bytes()
|
||||||
|
self.assertEqual(b"fake-binary-image-content", persisted)
|
||||||
|
history = asset["history"]
|
||||||
|
self.assertGreaterEqual(len(history), 1)
|
||||||
|
self.assertEqual("ASSET_FILE_UPLOADED", history[-1]["action"])
|
||||||
|
|
||||||
|
def test_approve_asset_writes_workflow_event(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_assembled_draft()
|
||||||
|
asset_id = self._generate_specs(article_id)[0]["id"]
|
||||||
|
self._upload_asset(article_id, asset_id, filename="approve.png", payload=b"approve")
|
||||||
|
|
||||||
|
approve_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/{asset_id}/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, approve_response.status_code, approve_response.text)
|
||||||
|
self.assertEqual("APPROVED", approve_response.json()["asset"]["status"])
|
||||||
|
|
||||||
|
article_detail = self.client.get(
|
||||||
|
f"/api/articles/{article_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, article_detail.status_code, article_detail.text)
|
||||||
|
events = article_detail.json()["workflow_events"]
|
||||||
|
approval_events = [event for event in events if event["event_type"] == "ASSET_APPROVED"]
|
||||||
|
self.assertTrue(approval_events, "Approval must write workflow event")
|
||||||
|
self.assertEqual(asset_id, approval_events[-1]["payload"]["asset_id"])
|
||||||
|
|
||||||
|
def test_rejected_assets_are_excluded_from_draft_publish_bundle(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_completed_scaffolds()
|
||||||
|
generated_assets = self._generate_specs(article_id)
|
||||||
|
self.assertGreaterEqual(len(generated_assets), 2)
|
||||||
|
approved_asset = generated_assets[0]
|
||||||
|
rejected_asset = generated_assets[1]
|
||||||
|
|
||||||
|
approved_upload = self._upload_asset(
|
||||||
|
article_id,
|
||||||
|
approved_asset["id"],
|
||||||
|
filename="approved.png",
|
||||||
|
payload=b"approved-image",
|
||||||
|
)
|
||||||
|
self._approve_asset(article_id, approved_asset["id"])
|
||||||
|
rejected_upload = self._upload_asset(
|
||||||
|
article_id,
|
||||||
|
rejected_asset["id"],
|
||||||
|
filename="rejected.png",
|
||||||
|
payload=b"rejected-image",
|
||||||
|
)
|
||||||
|
self._patch_asset(
|
||||||
|
article_id,
|
||||||
|
rejected_asset["id"],
|
||||||
|
request={"status": "REJECTED"},
|
||||||
|
)
|
||||||
|
|
||||||
|
draft = self._assemble_draft(article_id)
|
||||||
|
self.assertIn(approved_upload["file_url"], draft["body_markdown"])
|
||||||
|
self.assertNotIn(rejected_upload["file_url"], draft["body_markdown"])
|
||||||
|
self.assertIn(approved_upload["file_url"], "\n".join(draft["visual_placeholders"]))
|
||||||
|
self.assertNotIn(rejected_upload["file_url"], "\n".join(draft["visual_placeholders"]))
|
||||||
|
|
||||||
|
def test_replacement_preserves_audit_history(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_assembled_draft()
|
||||||
|
asset_id = self._generate_specs(article_id)[0]["id"]
|
||||||
|
first = self._upload_asset(article_id, asset_id, filename="first.png", payload=b"first")
|
||||||
|
second = self._upload_asset(article_id, asset_id, filename="second.png", payload=b"second")
|
||||||
|
self.assertNotEqual(first["object_key"], second["object_key"])
|
||||||
|
|
||||||
|
list_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/assets",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, list_response.status_code, list_response.text)
|
||||||
|
asset = next(item for item in list_response.json()["assets"] if item["id"] == asset_id)
|
||||||
|
history = asset["history"]
|
||||||
|
actions = [item["action"] for item in history]
|
||||||
|
self.assertIn("ASSET_FILE_UPLOADED", actions)
|
||||||
|
self.assertIn("ASSET_FILE_REPLACED", actions)
|
||||||
|
replaced_event = next(item for item in history if item["action"] == "ASSET_FILE_REPLACED")
|
||||||
|
self.assertEqual(first["object_key"], replaced_event["payload"]["previous_object_key"])
|
||||||
|
self.assertEqual(second["object_key"], replaced_event["payload"]["next_object_key"])
|
||||||
|
|
||||||
|
def test_media_library_lists_assets_with_required_fields(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_assembled_draft()
|
||||||
|
asset_id = self._generate_specs(article_id)[0]["id"]
|
||||||
|
self._upload_asset(article_id, asset_id, filename="library.png", payload=b"library")
|
||||||
|
self._patch_asset(
|
||||||
|
article_id,
|
||||||
|
asset_id,
|
||||||
|
request={
|
||||||
|
"title": "Library Hero",
|
||||||
|
"alt_text": "Dashboard on laptop screen",
|
||||||
|
"caption": "Screenshot used in introduction section.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
list_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/assets",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, list_response.status_code, list_response.text)
|
||||||
|
asset = next(item for item in list_response.json()["assets"] if item["id"] == asset_id)
|
||||||
|
self.assertEqual("Library Hero", asset["title"])
|
||||||
|
self.assertTrue(asset["asset_type"])
|
||||||
|
self.assertTrue(asset["status"])
|
||||||
|
self.assertTrue(asset["file_url"])
|
||||||
|
self.assertEqual("Dashboard on laptop screen", asset["alt_text"])
|
||||||
|
self.assertEqual("Screenshot used in introduction section.", asset["caption"])
|
||||||
|
|
||||||
|
def test_draft_preview_uses_approved_asset_references(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_completed_scaffolds()
|
||||||
|
asset = self._generate_specs(article_id)[0]
|
||||||
|
uploaded = self._upload_asset(
|
||||||
|
article_id,
|
||||||
|
asset["id"],
|
||||||
|
filename="preview.png",
|
||||||
|
payload=b"preview",
|
||||||
|
)
|
||||||
|
self._approve_asset(article_id, asset["id"])
|
||||||
|
|
||||||
|
draft = self._assemble_draft(article_id)
|
||||||
|
self.assertIn(uploaded["file_url"], draft["body_markdown"])
|
||||||
|
self.assertIn(uploaded["file_url"], "\n".join(draft["visual_placeholders"]))
|
||||||
|
|
||||||
|
def _assemble_draft(self, article_id: UUID) -> dict[str, Any]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/assemble",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, response.status_code, response.text)
|
||||||
|
return response.json()["draft"]
|
||||||
|
|
||||||
|
def _upload_asset(self, article_id: UUID, asset_id: str, *, filename: str, payload: bytes) -> dict[str, Any]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/{asset_id}/upload",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"filename": filename,
|
||||||
|
"content_base64": base64.b64encode(payload).decode("utf-8"),
|
||||||
|
"content_type": "image/png",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
return response.json()["asset"]
|
||||||
|
|
||||||
|
def _approve_asset(self, article_id: UUID, asset_id: str) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/{asset_id}/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
|
||||||
|
def _patch_asset(self, article_id: UUID, asset_id: str, *, request: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/assets/{asset_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json=request,
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
return response.json()["asset"]
|
||||||
|
|
||||||
|
def _generate_specs(self, article_id: UUID) -> list[dict[str, Any]]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/generate-specs",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, response.status_code, response.text)
|
||||||
|
return response.json()["assets"]
|
||||||
|
|
||||||
|
def _prepare_article_with_assembled_draft(self) -> UUID:
|
||||||
|
article_id = self._prepare_article_with_completed_scaffolds()
|
||||||
|
self._assemble_draft(article_id)
|
||||||
|
return article_id
|
||||||
|
|
||||||
|
def _prepare_article_with_completed_scaffolds(self) -> UUID:
|
||||||
|
article_id, _ = self._prepare_article_with_approved_plan()
|
||||||
|
self._ensure_evidence_ready(article_id)
|
||||||
|
section_jobs = self._start_parallel_production(article_id)
|
||||||
|
self._complete_all_section_jobs_success(section_jobs)
|
||||||
|
return article_id
|
||||||
|
|
||||||
|
def _prepare_article_with_approved_plan(self) -> tuple[UUID, dict[str, Any]]:
|
||||||
|
site = self.client.get(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()[0]["site"]
|
||||||
|
article = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"target_site_id": site["id"],
|
||||||
|
"brief_description": "Assets library integration coverage",
|
||||||
|
"working_title": "Assets and Media Library",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "assets media library",
|
||||||
|
},
|
||||||
|
).json()["article"]
|
||||||
|
article_id = UUID(article["id"])
|
||||||
|
|
||||||
|
questions = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()["questions"]
|
||||||
|
for question in questions:
|
||||||
|
if question["is_required"]:
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"answer": f"Answer for {question['category']}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
|
||||||
|
submit_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, submit_response.status_code, submit_response.text)
|
||||||
|
|
||||||
|
plan_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plan/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, plan_response.status_code, plan_response.text)
|
||||||
|
plan = plan_response.json()["plan"]
|
||||||
|
self.assertGreaterEqual(len(plan["sections"]), 1)
|
||||||
|
|
||||||
|
approve_plan_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, approve_plan_response.status_code, approve_plan_response.text)
|
||||||
|
return article_id, plan
|
||||||
|
|
||||||
|
def _complete_all_section_jobs_success(self, section_jobs: list[dict[str, Any]]) -> None:
|
||||||
|
for index, job in enumerate(section_jobs, start=1):
|
||||||
|
complete_response = self.client.post(
|
||||||
|
f"/internal/agent-jobs/{job['id']}/complete",
|
||||||
|
json={
|
||||||
|
"workspace_path": f"/tmp/{job['id']}",
|
||||||
|
"stdout": "fake section scaffolding runner\n",
|
||||||
|
"stderr": "",
|
||||||
|
"exit_code": 0,
|
||||||
|
"duration_ms": 1,
|
||||||
|
"output": {
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": f"outputs/section-{index}.md"}],
|
||||||
|
"payload": {
|
||||||
|
"used_evidence_ids": job["payload"]["used_evidence_ids"],
|
||||||
|
"unsupported_claims": [],
|
||||||
|
"draft_markdown": f"## {job['payload']['heading']}\n\nSection {index}.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, complete_response.status_code, complete_response.text)
|
||||||
|
self.assertEqual("SUCCEEDED", complete_response.json()["job"]["status"])
|
||||||
|
|
||||||
|
def _start_parallel_production(self, article_id: UUID) -> list[dict[str, Any]]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(202, response.status_code, response.text)
|
||||||
|
jobs = response.json()["jobs"]
|
||||||
|
section_jobs = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"]
|
||||||
|
self.assertTrue(section_jobs)
|
||||||
|
return section_jobs
|
||||||
|
|
||||||
|
def _ensure_evidence_ready(self, article_id: UUID) -> None:
|
||||||
|
research_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/research/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, research_response.status_code, research_response.text)
|
||||||
|
|
||||||
|
evidence_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/evidence",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
|
||||||
|
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||||
|
DEMO_ADMIN_EMAIL = "admin@example.com"
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class AuthAuthorizationPublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def test_me_returns_selected_demo_user_and_role(self) -> None:
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/me",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
self.assertEqual(DEMO_ADMIN_EMAIL, response.json()["user"]["email"])
|
||||||
|
self.assertEqual("ADMIN", response.json()["user"]["role"])
|
||||||
|
|
||||||
|
def test_missing_and_unknown_demo_users_are_rejected_consistently(self) -> None:
|
||||||
|
missing_response = self.client.get("/api/me")
|
||||||
|
unknown_response = self.client.get(
|
||||||
|
"/api/me",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: "missing@example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(401, missing_response.status_code, missing_response.text)
|
||||||
|
self.assertEqual(401, unknown_response.status_code, unknown_response.text)
|
||||||
|
self.assertEqual({"detail": "Unauthorized"}, missing_response.json())
|
||||||
|
self.assertEqual({"detail": "Unauthorized"}, unknown_response.json())
|
||||||
|
|
||||||
|
def test_editor_cannot_create_target_site_config(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json=self._site_payload("editorial_ops_blog"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(403, response.status_code, response.text)
|
||||||
|
self.assertEqual({"detail": "Forbidden"}, response.json())
|
||||||
|
|
||||||
|
def test_admin_can_create_and_update_target_site_config(self) -> None:
|
||||||
|
create_response = self.client.post(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json=self._site_payload("admin_ops_blog"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||||
|
site = create_response.json()["site"]
|
||||||
|
self.assertEqual("admin_ops_blog", site["slug"])
|
||||||
|
|
||||||
|
update_response = self.client.patch(
|
||||||
|
f"/api/sites/{site['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json={"brand_voice": "Updated practical editorial operations guidance."},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, update_response.status_code, update_response.text)
|
||||||
|
self.assertEqual(
|
||||||
|
"Updated practical editorial operations guidance.",
|
||||||
|
update_response.json()["site"]["brand_voice"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_editor_cannot_update_site_or_mutate_script_versions(self) -> None:
|
||||||
|
create_response = self.client.post(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json=self._site_payload("script_editor_denied_blog"),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||||
|
site_id = create_response.json()["site"]["id"]
|
||||||
|
|
||||||
|
version_response = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json=self._script_version_payload(),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, version_response.status_code, version_response.text)
|
||||||
|
version_id = version_response.json()["version"]["id"]
|
||||||
|
|
||||||
|
site_update_response = self.client.patch(
|
||||||
|
f"/api/sites/{site_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"brand_voice": "Editor should not mutate Admin config."},
|
||||||
|
)
|
||||||
|
version_create_response = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json=self._script_version_payload(),
|
||||||
|
)
|
||||||
|
version_activate_response = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions/{version_id}/activate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(403, site_update_response.status_code, site_update_response.text)
|
||||||
|
self.assertEqual(
|
||||||
|
403,
|
||||||
|
version_create_response.status_code,
|
||||||
|
version_create_response.text,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
403,
|
||||||
|
version_activate_response.status_code,
|
||||||
|
version_activate_response.text,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_editor_cannot_rollback_script_config_version(self) -> None:
|
||||||
|
site_response = self.client.post(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json=self._site_payload("rollback_denied_blog"),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, site_response.status_code, site_response.text)
|
||||||
|
site_id = site_response.json()["site"]["id"]
|
||||||
|
|
||||||
|
first_version = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json=self._script_version_payload(),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, first_version.status_code, first_version.text)
|
||||||
|
version_id = first_version.json()["version"]["id"]
|
||||||
|
|
||||||
|
activate_response = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions/{version_id}/activate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
rollback_response = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions/{version_id}/rollback",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(403, activate_response.status_code, activate_response.text)
|
||||||
|
self.assertEqual(403, rollback_response.status_code, rollback_response.text)
|
||||||
|
|
||||||
|
def test_admin_can_create_and_activate_script_config_version(self) -> None:
|
||||||
|
site_response = self.client.post(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json=self._site_payload("script_admin_blog"),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, site_response.status_code, site_response.text)
|
||||||
|
site_id = site_response.json()["site"]["id"]
|
||||||
|
|
||||||
|
create_response = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json=self._script_version_payload(),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||||
|
self.assertEqual("DRAFT", create_response.json()["version"]["status"])
|
||||||
|
|
||||||
|
version_id = create_response.json()["version"]["id"]
|
||||||
|
activate_response = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions/{version_id}/activate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, activate_response.status_code, activate_response.text)
|
||||||
|
self.assertEqual("ACTIVE", activate_response.json()["version"]["status"])
|
||||||
|
|
||||||
|
def test_admin_can_get_site_by_id(self) -> None:
|
||||||
|
site_response = self.client.post(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json=self._site_payload("admin_get_site_by_id_blog"),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, site_response.status_code, site_response.text)
|
||||||
|
site_id = site_response.json()["site"]["id"]
|
||||||
|
|
||||||
|
get_response = self.client.get(
|
||||||
|
f"/api/sites/{site_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, get_response.status_code, get_response.text)
|
||||||
|
self.assertEqual(site_id, get_response.json()["site"]["id"])
|
||||||
|
|
||||||
|
def test_admin_can_create_version_with_rollback_target(self) -> None:
|
||||||
|
site_response = self.client.post(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json=self._site_payload("rollback_target_blog"),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, site_response.status_code, site_response.text)
|
||||||
|
site_id = site_response.json()["site"]["id"]
|
||||||
|
|
||||||
|
baseline = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json=self._script_version_payload(diff={"summary": "baseline"}),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, baseline.status_code, baseline.text)
|
||||||
|
baseline_id = baseline.json()["version"]["id"]
|
||||||
|
|
||||||
|
create_response = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json=self._script_version_payload(
|
||||||
|
diff={"summary": "rollback-aware"},
|
||||||
|
rollback_target_version_id=baseline_id,
|
||||||
|
activate=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||||
|
created = create_response.json()["version"]
|
||||||
|
|
||||||
|
self.assertEqual("ACTIVE", created["status"])
|
||||||
|
self.assertEqual("rollback-aware", created["diff"].get("summary"))
|
||||||
|
self.assertEqual(baseline_id, created["rollback_target_version_id"])
|
||||||
|
|
||||||
|
versions_response = self.client.get(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, versions_response.status_code, versions_response.text)
|
||||||
|
self.assertEqual(2, len(versions_response.json()["versions"]))
|
||||||
|
|
||||||
|
def test_admin_can_rollback_script_config_version(self) -> None:
|
||||||
|
site_response = self.client.post(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json=self._site_payload("rollback_action_blog"),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, site_response.status_code, site_response.text)
|
||||||
|
site_id = site_response.json()["site"]["id"]
|
||||||
|
|
||||||
|
active = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json=self._script_version_payload(
|
||||||
|
diff={"summary": "initial active"},
|
||||||
|
activate=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, active.status_code, active.text)
|
||||||
|
active_id = active.json()["version"]["id"]
|
||||||
|
|
||||||
|
next_version = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json=self._script_version_payload(diff={"summary": "next version"}),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, next_version.status_code, next_version.text)
|
||||||
|
next_id = next_version.json()["version"]["id"]
|
||||||
|
|
||||||
|
self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions/{next_id}/activate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
|
||||||
|
rollback_response = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions/{active_id}/rollback",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, rollback_response.status_code, rollback_response.text)
|
||||||
|
self.assertEqual(active_id, rollback_response.json()["version"]["id"])
|
||||||
|
self.assertEqual(
|
||||||
|
"ACTIVE",
|
||||||
|
rollback_response.json()["version"]["status"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
next_id,
|
||||||
|
rollback_response.json()["version"]["rollback_target_version_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
get_response = self.client.get(
|
||||||
|
f"/api/sites/{site_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, get_response.status_code, get_response.text)
|
||||||
|
self.assertEqual(
|
||||||
|
active_id,
|
||||||
|
get_response.json()["site"]["active_script_config_version_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_editor_can_create_articles_and_approve_plan_review(self) -> None:
|
||||||
|
sites_response = self.client.get(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, sites_response.status_code, sites_response.text)
|
||||||
|
target_site_id = sites_response.json()[0]["site"]["id"]
|
||||||
|
|
||||||
|
article_response = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"target_site_id": target_site_id,
|
||||||
|
"brief_description": "Write about editorial AI workflow controls.",
|
||||||
|
"working_title": "Editorial AI Workflow Controls",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(201, article_response.status_code, article_response.text)
|
||||||
|
self.assertEqual(
|
||||||
|
"ARTICLE_BRIEF_CREATED",
|
||||||
|
article_response.json()["article"]["status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
article_id = article_response.json()["article"]["id"]
|
||||||
|
questions_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, questions_response.status_code, questions_response.text)
|
||||||
|
for question in questions_response.json()["questions"]:
|
||||||
|
if question["is_required"]:
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"answer": f"Answer for {question['category']}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
|
||||||
|
submit_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, submit_response.status_code, submit_response.text)
|
||||||
|
|
||||||
|
plan_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plan/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, plan_response.status_code, plan_response.text)
|
||||||
|
plan_id = plan_response.json()["plan"]["id"]
|
||||||
|
|
||||||
|
review_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plans/{plan_id}/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, review_response.status_code, review_response.text)
|
||||||
|
self.assertEqual("PLAN", review_response.json()["review"]["review_type"])
|
||||||
|
self.assertEqual("APPROVED", review_response.json()["review"]["status"])
|
||||||
|
|
||||||
|
def test_unauthorized_requests_are_rejected_for_protected_mutations(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
json={
|
||||||
|
"target_site_id": "00000000-0000-0000-0000-000000000001",
|
||||||
|
"brief_description": "No user selected.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(401, response.status_code, response.text)
|
||||||
|
self.assertEqual({"detail": "Unauthorized"}, response.json())
|
||||||
|
|
||||||
|
def _site_payload(self, slug: str) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"name": "Editorial Ops Blog",
|
||||||
|
"slug": slug,
|
||||||
|
"publishing_type": "git_next",
|
||||||
|
"default_language": "en",
|
||||||
|
"brand_voice": "Practical editorial operations guidance.",
|
||||||
|
"audience": "Editorial teams running AI-assisted workflows.",
|
||||||
|
"seo_rules": {"primary_keyword_required": True},
|
||||||
|
"visual_rules": {"hero_style": "product editorial"},
|
||||||
|
"source_rules": {"minimum_sources": 3},
|
||||||
|
"publishing_rules": {
|
||||||
|
"repository_url": "git@github.com:example/editorial-ops.git",
|
||||||
|
"production_branch": "main",
|
||||||
|
"content_format": "mdx",
|
||||||
|
"content_path_template": "content/articles/{slug}.mdx",
|
||||||
|
"asset_path_template": "public/articles/{slug}/{filename}",
|
||||||
|
"frontmatter_mapping": {"title": "title"},
|
||||||
|
"dry_run_renderer": "next-mdx",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _script_version_payload(
|
||||||
|
self,
|
||||||
|
diff: dict[str, object] | None = None,
|
||||||
|
rollback_target_version_id: str | None = None,
|
||||||
|
activate: bool = False,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
payload = {
|
||||||
|
"publishing_yaml": "target: editorial_ops\nrepository:\n branch: main\n",
|
||||||
|
"transform_script": "export function transformArticle(article) { return article; }\n",
|
||||||
|
"diff": diff or {"summary": "Task 004 authorization fixture."},
|
||||||
|
}
|
||||||
|
if rollback_target_version_id is not None:
|
||||||
|
payload["rollback_target_version_id"] = rollback_target_version_id
|
||||||
|
if activate:
|
||||||
|
payload["activate"] = True
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||||
|
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||||
|
from src.presentation.dependencies import get_repository # noqa: E402
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class BoundaryQuestionsPublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'boundary-questions.db'}"
|
||||||
|
self.repository = open_backend_repository(dsn)
|
||||||
|
self.repository.setup()
|
||||||
|
seed_reference_data(self.repository)
|
||||||
|
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
self.tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_required_unanswered_questions_block_submission_then_submit_updates_workflow(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
article_id = self._create_article()
|
||||||
|
|
||||||
|
blocked_plan_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plans/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(409, blocked_plan_response.status_code, blocked_plan_response.text)
|
||||||
|
|
||||||
|
generate_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, generate_response.status_code, generate_response.text)
|
||||||
|
generated = generate_response.json()
|
||||||
|
questions = generated["questions"]
|
||||||
|
self.assertGreaterEqual(len(questions), 5)
|
||||||
|
self.assertLessEqual(len(questions), 10)
|
||||||
|
self.assertEqual("BOUNDARY_QUESTIONS", generated["job"]["job_type"])
|
||||||
|
self.assertEqual("SUCCEEDED", generated["job"]["status"])
|
||||||
|
|
||||||
|
categories = {question["category"] for question in questions}
|
||||||
|
for category in (
|
||||||
|
"audience",
|
||||||
|
"purpose",
|
||||||
|
"reader_outcome",
|
||||||
|
"depth",
|
||||||
|
"tone",
|
||||||
|
"excluded_topics",
|
||||||
|
"primary_keyword",
|
||||||
|
"competitor_angle",
|
||||||
|
"evidence_standard",
|
||||||
|
"visual_expectations",
|
||||||
|
):
|
||||||
|
self.assertIn(category, categories)
|
||||||
|
|
||||||
|
required_question = next(
|
||||||
|
question for question in questions if question["is_required"]
|
||||||
|
)
|
||||||
|
optional_question = next(
|
||||||
|
question for question in questions if not question["is_required"]
|
||||||
|
)
|
||||||
|
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/{required_question['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"answer": "Senior editors evaluating automated SEO content."},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
self.assertEqual(
|
||||||
|
"Senior editors evaluating automated SEO content.",
|
||||||
|
patch_response.json()["question"]["answer"],
|
||||||
|
)
|
||||||
|
|
||||||
|
optional_patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/{optional_question['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"answer": "No custom visual constraints."},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, optional_patch_response.status_code, optional_patch_response.text)
|
||||||
|
|
||||||
|
submit_blocked_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(409, submit_blocked_response.status_code, submit_blocked_response.text)
|
||||||
|
self.assertIn("required", submit_blocked_response.json()["detail"])
|
||||||
|
|
||||||
|
list_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, list_response.status_code, list_response.text)
|
||||||
|
saved_required = self._find_question(
|
||||||
|
list_response.json()["questions"],
|
||||||
|
required_question["id"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"Senior editors evaluating automated SEO content.",
|
||||||
|
saved_required["answer"],
|
||||||
|
)
|
||||||
|
|
||||||
|
for question in list_response.json()["questions"]:
|
||||||
|
if question["is_required"] and not question["answer"]:
|
||||||
|
answer_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"answer": f"Accepted answer for {question['category']}."},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, answer_response.status_code, answer_response.text)
|
||||||
|
|
||||||
|
submit_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, submit_response.status_code, submit_response.text)
|
||||||
|
self.assertEqual(
|
||||||
|
"BOUNDARY_ANSWERS_SUBMITTED",
|
||||||
|
submit_response.json()["article"]["status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
detail_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, detail_response.status_code, detail_response.text)
|
||||||
|
detail = detail_response.json()
|
||||||
|
self.assertEqual("BOUNDARY_ANSWERS_SUBMITTED", detail["article"]["status"])
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
event["event_type"] == "BOUNDARY_ANSWERS_SUBMITTED"
|
||||||
|
for event in detail["workflow_events"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed_plan_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plans/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(202, allowed_plan_response.status_code, allowed_plan_response.text)
|
||||||
|
|
||||||
|
def _create_article(self) -> str:
|
||||||
|
site_response = self.client.get(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, site_response.status_code, site_response.text)
|
||||||
|
target_site = site_response.json()[0]["site"]
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"target_site_id": target_site["id"],
|
||||||
|
"brief_description": "Build a precise comparison of AI content systems.",
|
||||||
|
"working_title": "AI Content Systems",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "AI content systems",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, response.status_code, response.text)
|
||||||
|
return response.json()["article"]["id"]
|
||||||
|
|
||||||
|
def _find_question(
|
||||||
|
self,
|
||||||
|
questions: list[dict[str, Any]],
|
||||||
|
question_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
for question in questions:
|
||||||
|
if question["id"] == question_id:
|
||||||
|
return question
|
||||||
|
self.fail(f"Question not found: {question_id}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||||
|
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||||
|
from src.presentation.dependencies import get_repository # noqa: E402
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class DemoBrowserCorsPublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'demo-browser-cors.db'}"
|
||||||
|
self.repository = open_backend_repository(dsn)
|
||||||
|
self.repository.setup()
|
||||||
|
seed_reference_data(self.repository)
|
||||||
|
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
self.tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_demo_frontend_origin_can_call_backend_with_demo_user_header(self) -> None:
|
||||||
|
response = self.client.options(
|
||||||
|
"/api/admin/workflows",
|
||||||
|
headers={
|
||||||
|
"Origin": "http://localhost:13300",
|
||||||
|
"Access-Control-Request-Method": "GET",
|
||||||
|
"Access-Control-Request-Headers": "X-Demo-User-Email",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
self.assertEqual(
|
||||||
|
"http://localhost:13300",
|
||||||
|
response.headers.get("access-control-allow-origin"),
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"X-Demo-User-Email",
|
||||||
|
response.headers.get("access-control-allow-headers", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||||
|
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||||
|
from src.presentation.dependencies import get_repository # noqa: E402
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||||
|
DEMO_ADMIN_EMAIL = "admin@example.com"
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class DraftAssemblyPublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects")
|
||||||
|
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'draft-assembly.db'}"
|
||||||
|
self.repository = open_backend_repository(dsn)
|
||||||
|
self.repository.setup()
|
||||||
|
seed_reference_data(self.repository)
|
||||||
|
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
|
||||||
|
self.tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_assemble_draft_includes_all_sections_in_order_and_full_payload(self) -> None:
|
||||||
|
article_id, section_headings = self._prepare_article_for_draft_assembly()
|
||||||
|
section_jobs = self._start_parallel_production(article_id)
|
||||||
|
|
||||||
|
for index, job in enumerate(section_jobs, start=1):
|
||||||
|
if index == 1:
|
||||||
|
draft_markdown = (
|
||||||
|
f"## {job['payload']['heading']}\n\n"
|
||||||
|
"Use [official documentation](https://example.com/docs) for setup.\n\n"
|
||||||
|
"| Step | Result |\n"
|
||||||
|
"| --- | --- |\n"
|
||||||
|
"| 1 | Ready |\n\n"
|
||||||
|
"\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
draft_markdown = (
|
||||||
|
f"## {job['payload']['heading']}\n\n"
|
||||||
|
f"Body for section {index}."
|
||||||
|
)
|
||||||
|
completed = self._complete_job(
|
||||||
|
job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": f"outputs/section-{index}.md"}],
|
||||||
|
"payload": {
|
||||||
|
"used_evidence_ids": job["payload"]["used_evidence_ids"],
|
||||||
|
"unsupported_claims": [],
|
||||||
|
"suggested_visuals": [{"title": f"Visual {index}"}],
|
||||||
|
"draft_markdown": draft_markdown,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("SUCCEEDED", completed["status"])
|
||||||
|
|
||||||
|
assemble_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/assemble",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, assemble_response.status_code, assemble_response.text)
|
||||||
|
|
||||||
|
draft = assemble_response.json()["draft"]
|
||||||
|
self.assertEqual(1, draft["version"])
|
||||||
|
self.assertTrue(draft["meta_title"])
|
||||||
|
self.assertTrue(draft["meta_description"])
|
||||||
|
self.assertGreaterEqual(len(draft["faq_items"]), 1)
|
||||||
|
self.assertGreaterEqual(len(draft["visual_placeholders"]), 1)
|
||||||
|
self.assertGreaterEqual(len(draft["evidence_references"]), 1)
|
||||||
|
self.assertIn("unsupported_claim_warnings", draft)
|
||||||
|
|
||||||
|
body_markdown = draft["body_markdown"]
|
||||||
|
cursor = -1
|
||||||
|
for heading in section_headings:
|
||||||
|
position = body_markdown.find(f"## {heading}")
|
||||||
|
self.assertGreater(position, cursor)
|
||||||
|
cursor = position
|
||||||
|
|
||||||
|
self.assertIn("## Evidence References", body_markdown)
|
||||||
|
self.assertIn("## Visual Placeholders", body_markdown)
|
||||||
|
self.assertIn("## FAQ", body_markdown)
|
||||||
|
self.assertIn("| Step | Result |", body_markdown)
|
||||||
|
self.assertIn("[official documentation](https://example.com/docs)", body_markdown)
|
||||||
|
self.assertIn("", body_markdown)
|
||||||
|
|
||||||
|
drafts_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/drafts",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, drafts_response.status_code, drafts_response.text)
|
||||||
|
self.assertEqual(1, len(drafts_response.json()["drafts"]))
|
||||||
|
|
||||||
|
draft_id = draft["id"]
|
||||||
|
draft_detail_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/drafts/{draft_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, draft_detail_response.status_code, draft_detail_response.text)
|
||||||
|
self.assertEqual(draft["body_markdown"], draft_detail_response.json()["draft"]["body_markdown"])
|
||||||
|
|
||||||
|
def test_patch_creates_new_immutable_draft_version(self) -> None:
|
||||||
|
article_id, _ = self._prepare_article_for_draft_assembly()
|
||||||
|
section_jobs = self._start_parallel_production(article_id)
|
||||||
|
self._complete_all_sections_success(section_jobs)
|
||||||
|
first_draft = self._assemble(article_id)
|
||||||
|
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/drafts/{first_draft['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"meta_title": "Updated metadata title",
|
||||||
|
"body_markdown": first_draft["body_markdown"] + "\n\nRevision marker.\n",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
second_draft = patch_response.json()["draft"]
|
||||||
|
self.assertEqual(2, second_draft["version"])
|
||||||
|
self.assertEqual(first_draft["id"], second_draft["based_on_draft_id"])
|
||||||
|
self.assertEqual("Updated metadata title", second_draft["meta_title"])
|
||||||
|
self.assertIn("Revision marker.", second_draft["body_markdown"])
|
||||||
|
|
||||||
|
first_draft_refresh = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/drafts/{first_draft['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()["draft"]
|
||||||
|
self.assertEqual(1, first_draft_refresh["version"])
|
||||||
|
self.assertNotIn("Revision marker.", first_draft_refresh["body_markdown"])
|
||||||
|
self.assertNotEqual(second_draft["meta_title"], first_draft_refresh["meta_title"])
|
||||||
|
|
||||||
|
list_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/drafts",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, list_response.status_code, list_response.text)
|
||||||
|
versions = [item["version"] for item in list_response.json()["drafts"]]
|
||||||
|
self.assertEqual([2, 1], versions)
|
||||||
|
|
||||||
|
def test_assemble_fails_with_clear_conflict_when_scaffold_missing(self) -> None:
|
||||||
|
article_id, section_headings = self._prepare_article_for_draft_assembly()
|
||||||
|
section_jobs = self._start_parallel_production(article_id)
|
||||||
|
self.assertGreaterEqual(len(section_jobs), 2)
|
||||||
|
|
||||||
|
for job in section_jobs[:-1]:
|
||||||
|
completed = self._complete_job(
|
||||||
|
job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": "outputs/ready.md"}],
|
||||||
|
"payload": {
|
||||||
|
"used_evidence_ids": job["payload"]["used_evidence_ids"],
|
||||||
|
"unsupported_claims": [],
|
||||||
|
"draft_markdown": f"## {job['payload']['heading']}\n\nReady.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("SUCCEEDED", completed["status"])
|
||||||
|
|
||||||
|
missing_heading = section_headings[-1]
|
||||||
|
assemble_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/assemble",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(409, assemble_response.status_code, assemble_response.text)
|
||||||
|
self.assertIn("Missing successful section scaffolds", assemble_response.text)
|
||||||
|
self.assertIn(missing_heading, assemble_response.text)
|
||||||
|
|
||||||
|
def test_assemble_carries_unsupported_claim_warnings_from_scaffold_attempts(self) -> None:
|
||||||
|
article_id, _ = self._prepare_article_for_draft_assembly()
|
||||||
|
section_jobs = self._start_parallel_production(article_id)
|
||||||
|
self.assertGreaterEqual(len(section_jobs), 1)
|
||||||
|
|
||||||
|
first_job = section_jobs[0]
|
||||||
|
failed = self._complete_job(
|
||||||
|
first_job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": "outputs/failed.md"}],
|
||||||
|
"payload": {
|
||||||
|
"used_evidence_ids": first_job["payload"]["used_evidence_ids"],
|
||||||
|
"unsupported_claims": [
|
||||||
|
{"claim_text": "Unverified migration metric", "risk_level": "high"}
|
||||||
|
],
|
||||||
|
"draft_markdown": "Content with unsupported claim",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("FAILED", failed["status"])
|
||||||
|
|
||||||
|
retry_response = self.client.post(
|
||||||
|
f"/api/agent-jobs/{first_job['id']}/retry",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, retry_response.status_code, retry_response.text)
|
||||||
|
retry_job = retry_response.json()["job"]
|
||||||
|
retry_completed = self._complete_job(
|
||||||
|
retry_job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": "outputs/retry.md"}],
|
||||||
|
"payload": {
|
||||||
|
"used_evidence_ids": retry_job["payload"]["used_evidence_ids"],
|
||||||
|
"unsupported_claims": [],
|
||||||
|
"draft_markdown": f"## {retry_job['payload']['heading']}\n\nClean section.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("SUCCEEDED", retry_completed["status"])
|
||||||
|
|
||||||
|
for job in section_jobs[1:]:
|
||||||
|
self._complete_job(
|
||||||
|
job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": "outputs/ok.md"}],
|
||||||
|
"payload": {
|
||||||
|
"used_evidence_ids": job["payload"]["used_evidence_ids"],
|
||||||
|
"unsupported_claims": [],
|
||||||
|
"draft_markdown": f"## {job['payload']['heading']}\n\nReady.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
draft = self._assemble(article_id)
|
||||||
|
warnings = draft["unsupported_claim_warnings"]
|
||||||
|
self.assertTrue(any("Unverified migration metric" in warning for warning in warnings))
|
||||||
|
self.assertIn("## Unsupported Claim Warnings", draft["body_markdown"])
|
||||||
|
|
||||||
|
def test_draft_endpoints_return_404_for_missing_resources(self) -> None:
|
||||||
|
random_article_id = uuid4()
|
||||||
|
list_response = self.client.get(
|
||||||
|
f"/api/articles/{random_article_id}/drafts",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(404, list_response.status_code, list_response.text)
|
||||||
|
|
||||||
|
article_id, _ = self._prepare_article_for_draft_assembly()
|
||||||
|
random_draft_id = uuid4()
|
||||||
|
detail_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/drafts/{random_draft_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(404, detail_response.status_code, detail_response.text)
|
||||||
|
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/drafts/{random_draft_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"title": "Will fail"},
|
||||||
|
)
|
||||||
|
self.assertEqual(404, patch_response.status_code, patch_response.text)
|
||||||
|
|
||||||
|
def _assemble(self, article_id: str) -> dict[str, Any]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/assemble",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, response.status_code, response.text)
|
||||||
|
return response.json()["draft"]
|
||||||
|
|
||||||
|
def _complete_all_sections_success(self, section_jobs: list[dict[str, Any]]) -> None:
|
||||||
|
for index, job in enumerate(section_jobs, start=1):
|
||||||
|
completed = self._complete_job(
|
||||||
|
job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": f"outputs/{index}.md"}],
|
||||||
|
"payload": {
|
||||||
|
"used_evidence_ids": job["payload"]["used_evidence_ids"],
|
||||||
|
"unsupported_claims": [],
|
||||||
|
"draft_markdown": f"## {job['payload']['heading']}\n\nSection {index}.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("SUCCEEDED", completed["status"])
|
||||||
|
|
||||||
|
def _complete_job(self, job_id: str, *, output: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/internal/agent-jobs/{job_id}/complete",
|
||||||
|
json={
|
||||||
|
"workspace_path": f"/tmp/{job_id}",
|
||||||
|
"stdout": "fake section scaffolding runner\n",
|
||||||
|
"stderr": "",
|
||||||
|
"exit_code": 0,
|
||||||
|
"duration_ms": 1,
|
||||||
|
"output": output,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
return response.json()["job"]
|
||||||
|
|
||||||
|
def _start_parallel_production(self, article_id: str) -> list[dict[str, Any]]:
|
||||||
|
start_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(202, start_response.status_code, start_response.text)
|
||||||
|
jobs = start_response.json()["jobs"]
|
||||||
|
return [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"]
|
||||||
|
|
||||||
|
def _prepare_article_for_draft_assembly(self) -> tuple[str, list[str]]:
|
||||||
|
article_id, section_headings = self._create_article_with_approved_plan()
|
||||||
|
self._ensure_evidence_ready(article_id)
|
||||||
|
return article_id, section_headings
|
||||||
|
|
||||||
|
def _ensure_evidence_ready(self, article_id: str) -> None:
|
||||||
|
research_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/research/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, research_response.status_code, research_response.text)
|
||||||
|
|
||||||
|
evidence_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/evidence",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
|
||||||
|
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
|
||||||
|
self.assertFalse(evidence_response.json()["insufficient_evidence_reasons"])
|
||||||
|
|
||||||
|
def _create_article_with_approved_plan(self) -> tuple[str, list[str]]:
|
||||||
|
site = self.client.get(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()[0]["site"]
|
||||||
|
article = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"target_site_id": site["id"],
|
||||||
|
"brief_description": "Assemble draft from section scaffolds with versions.",
|
||||||
|
"working_title": "Draft Assembly With Versions",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "draft assembly editor preview",
|
||||||
|
},
|
||||||
|
).json()["article"]
|
||||||
|
article_id = article["id"]
|
||||||
|
|
||||||
|
questions = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()["questions"]
|
||||||
|
for question in questions:
|
||||||
|
if question["is_required"]:
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"answer": f"Answer for {question['category']}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
|
||||||
|
submit_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, submit_response.status_code, submit_response.text)
|
||||||
|
|
||||||
|
plan_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plan/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, plan_response.status_code, plan_response.text)
|
||||||
|
plan = plan_response.json()["plan"]
|
||||||
|
self.assertGreaterEqual(len(plan["sections"]), 1)
|
||||||
|
|
||||||
|
approve_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, approve_response.status_code, approve_response.text)
|
||||||
|
|
||||||
|
return article_id, [section["heading"] for section in plan["sections"]]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||||
|
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||||
|
from src.presentation.dependencies import get_repository # noqa: E402
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||||
|
DEMO_ADMIN_EMAIL = "admin@example.com"
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class EndToEndDemoStackSmokePublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.object_root = Path(self.tmp_dir.name) / "objects"
|
||||||
|
self.demo_repo_path = Path(self.tmp_dir.name) / "demo-site.git"
|
||||||
|
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(self.object_root)
|
||||||
|
os.environ["PIPELINE_DEMO_MODE"] = "1"
|
||||||
|
os.environ["PIPELINE_DEMO_PUBLISH_REPO_PATH"] = str(self.demo_repo_path)
|
||||||
|
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'demo-stack-smoke.db'}"
|
||||||
|
self.repository = open_backend_repository(dsn)
|
||||||
|
self.repository.setup()
|
||||||
|
seed_reference_data(self.repository)
|
||||||
|
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
|
||||||
|
os.environ.pop("PIPELINE_DEMO_MODE", None)
|
||||||
|
os.environ.pop("PIPELINE_DEMO_PUBLISH_REPO_PATH", None)
|
||||||
|
self.tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_demo_happy_path_reaches_publish_commit_created_with_failure_retry(self) -> None:
|
||||||
|
site_id = self._fetch_first_site_id()
|
||||||
|
article_id = self._create_article(site_id)
|
||||||
|
|
||||||
|
self._generate_submit_boundary_answers(article_id)
|
||||||
|
plan = self._generate_and_approve_plan(article_id)
|
||||||
|
self.assertGreaterEqual(len(plan["sections"]), 1)
|
||||||
|
|
||||||
|
research = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/research/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, research.status_code, research.text)
|
||||||
|
|
||||||
|
research_listing = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/research",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, research_listing.status_code, research_listing.text)
|
||||||
|
manifests = research_listing.json()["manifests"]
|
||||||
|
self.assertTrue(manifests)
|
||||||
|
self._assert_research_artifacts_exist(manifests)
|
||||||
|
|
||||||
|
evidence = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/evidence",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, evidence.status_code, evidence.text)
|
||||||
|
self.assertEqual("EVIDENCE_MATRIX_READY", evidence.json()["article"]["status"])
|
||||||
|
self._approve_all_evidence(article_id, evidence.json()["evidence"])
|
||||||
|
|
||||||
|
start_draft = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(202, start_draft.status_code, start_draft.text)
|
||||||
|
started_jobs = start_draft.json()["jobs"]
|
||||||
|
section_jobs = [job for job in started_jobs if job["job_type"] == "SECTION_SCAFFOLD"]
|
||||||
|
self.assertTrue(section_jobs)
|
||||||
|
failed_jobs = [job for job in section_jobs if job["status"] == "FAILED"]
|
||||||
|
self.assertTrue(failed_jobs, "Demo path must include visible failure before retry.")
|
||||||
|
|
||||||
|
retry = self.client.post(
|
||||||
|
f"/api/agent-jobs/{failed_jobs[0]['id']}/retry",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, retry.status_code, retry.text)
|
||||||
|
self.assertEqual("SUCCEEDED", retry.json()["job"]["status"])
|
||||||
|
|
||||||
|
assemble = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/assemble",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, assemble.status_code, assemble.text)
|
||||||
|
draft = assemble.json()["draft"]
|
||||||
|
|
||||||
|
assets = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/generate-specs",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, assets.status_code, assets.text)
|
||||||
|
generated_assets = assets.json()["assets"]
|
||||||
|
self.assertTrue(generated_assets)
|
||||||
|
self.assertTrue(
|
||||||
|
any(asset["asset_type"] in {"diagram", "table", "flowchart", "comparison_matrix", "architecture_diagram"} for asset in generated_assets)
|
||||||
|
)
|
||||||
|
self._approve_assets(article_id, generated_assets)
|
||||||
|
|
||||||
|
seo = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/seo/review",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, seo.status_code, seo.text)
|
||||||
|
language = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/language/review",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, language.status_code, language.text)
|
||||||
|
self._accept_all_review_suggestions(article_id, seo.json()["report"]["issues"], kind="seo")
|
||||||
|
self._accept_all_review_suggestions(article_id, language.json()["report"]["issues"], kind="language")
|
||||||
|
|
||||||
|
issues = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/final-review/issues",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, issues.status_code, issues.text)
|
||||||
|
self.assertEqual(0, issues.json()["unresolved_count"])
|
||||||
|
|
||||||
|
drafts = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/drafts",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, drafts.status_code, drafts.text)
|
||||||
|
latest_draft = max(drafts.json()["drafts"], key=lambda item: item["version"])
|
||||||
|
|
||||||
|
approval = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/final-approval",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"draft_version": latest_draft["version"],
|
||||||
|
"publishing_settings": {
|
||||||
|
"content_path": "/guides/task-019-demo-stack",
|
||||||
|
"author": "Demo Editor",
|
||||||
|
"publishing_mode": "MANUAL",
|
||||||
|
"frontmatter": {
|
||||||
|
"title": latest_draft["title"],
|
||||||
|
"category": "Demo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, approval.status_code, approval.text)
|
||||||
|
self.assertEqual("PUBLISH_DRY_RUN_REQUIRED", approval.json()["article"]["status"])
|
||||||
|
|
||||||
|
dry_run = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/publishing/dry-run",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, dry_run.status_code, dry_run.text)
|
||||||
|
self.assertTrue(dry_run.json()["content_shape_valid"])
|
||||||
|
self.assertEqual("PUBLISH_COMMIT_READY", dry_run.json()["article"]["status"])
|
||||||
|
|
||||||
|
commit = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/publishing/create-commit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, commit.status_code, commit.text)
|
||||||
|
publish_commit = commit.json()["publish_commit"]
|
||||||
|
self.assertEqual("PUBLISH_COMMIT_CREATED", publish_commit["status"])
|
||||||
|
self.assertEqual("PUBLISH_COMMIT_CREATED", commit.json()["article"]["status"])
|
||||||
|
self.assertEqual(str(self.demo_repo_path.resolve()), publish_commit["repository_url"])
|
||||||
|
|
||||||
|
self._assert_commit_payload_valid(publish_commit)
|
||||||
|
self._assert_timeline_contains_major_actions(article_id)
|
||||||
|
|
||||||
|
def _fetch_first_site_id(self) -> str:
|
||||||
|
site_response = self.client.get(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, site_response.status_code, site_response.text)
|
||||||
|
site = site_response.json()[0]["site"]
|
||||||
|
self.assertEqual(
|
||||||
|
str(self.demo_repo_path.resolve()),
|
||||||
|
site["publishing_rules"]["repository_url"],
|
||||||
|
)
|
||||||
|
return site["id"]
|
||||||
|
|
||||||
|
def _create_article(self, site_id: str) -> str:
|
||||||
|
article_response = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"target_site_id": site_id,
|
||||||
|
"brief_description": "Demo-stack shortest public happy path toward publish flow.",
|
||||||
|
"working_title": "Task 019 Demo Stack Smoke",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "demo stack smoke path",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, article_response.status_code, article_response.text)
|
||||||
|
return article_response.json()["article"]["id"]
|
||||||
|
|
||||||
|
def _generate_submit_boundary_answers(self, article_id: str) -> None:
|
||||||
|
questions_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, questions_response.status_code, questions_response.text)
|
||||||
|
questions = questions_response.json()["questions"]
|
||||||
|
for question in questions:
|
||||||
|
if question["is_required"]:
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"answer": f"Answer for {question['category']}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
submit_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, submit_response.status_code, submit_response.text)
|
||||||
|
|
||||||
|
def _generate_and_approve_plan(self, article_id: str) -> dict[str, object]:
|
||||||
|
plan_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plan/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, plan_response.status_code, plan_response.text)
|
||||||
|
plan = plan_response.json()["plan"]
|
||||||
|
approve_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, approve_response.status_code, approve_response.text)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
def _approve_assets(self, article_id: str, assets: list[dict[str, object]]) -> None:
|
||||||
|
fake_image = base64.b64encode(b"fake-image-bytes").decode("utf-8")
|
||||||
|
for asset in assets:
|
||||||
|
upload = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/{asset['id']}/upload",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"filename": f"{asset['id']}.png",
|
||||||
|
"content_base64": fake_image,
|
||||||
|
"content_type": "image/png",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, upload.status_code, upload.text)
|
||||||
|
approve = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/{asset['id']}/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, approve.status_code, approve.text)
|
||||||
|
|
||||||
|
def _approve_all_evidence(self, article_id: str, evidence_rows: list[dict[str, object]]) -> None:
|
||||||
|
for evidence in evidence_rows:
|
||||||
|
response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/evidence/{evidence['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"review_status": "APPROVED"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
|
||||||
|
def _accept_all_review_suggestions(
|
||||||
|
self,
|
||||||
|
article_id: str,
|
||||||
|
issues: list[dict[str, object]],
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
) -> None:
|
||||||
|
for issue in issues:
|
||||||
|
suggestion_id = issue["suggestion_id"]
|
||||||
|
endpoint = f"/api/articles/{article_id}/{kind}/suggestions/{suggestion_id}/accept"
|
||||||
|
response = self.client.post(
|
||||||
|
endpoint,
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
|
||||||
|
def _assert_research_artifacts_exist(self, manifests: list[dict[str, object]]) -> None:
|
||||||
|
for manifest in manifests:
|
||||||
|
artifacts = manifest.get("artifacts", [])
|
||||||
|
self.assertTrue(artifacts)
|
||||||
|
for artifact in artifacts:
|
||||||
|
object_key = artifact["object_key"]
|
||||||
|
path = self.object_root / object_key
|
||||||
|
self.assertTrue(path.exists(), f"Missing research artifact: {path}")
|
||||||
|
|
||||||
|
def _assert_commit_payload_valid(self, publish_commit: dict[str, object]) -> None:
|
||||||
|
manifest = publish_commit["content_bundle_manifest"]
|
||||||
|
content_path = manifest["content"]["path"]
|
||||||
|
assets = manifest["assets"]
|
||||||
|
commit_sha = publish_commit["commit_sha"]
|
||||||
|
|
||||||
|
markdown = self._git_show(commit_sha, content_path)
|
||||||
|
self.assertIn("---", markdown)
|
||||||
|
self.assertIn("title:", markdown)
|
||||||
|
self.assertIn("author:", markdown)
|
||||||
|
self.assertIn("##", markdown)
|
||||||
|
|
||||||
|
self.assertTrue(assets)
|
||||||
|
for asset in assets:
|
||||||
|
target = asset["target_path"]
|
||||||
|
blob = self._git_show(commit_sha, target)
|
||||||
|
self.assertTrue(blob)
|
||||||
|
|
||||||
|
def _assert_timeline_contains_major_actions(self, article_id: str) -> None:
|
||||||
|
detail = self.client.get(
|
||||||
|
f"/api/articles/{article_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, detail.status_code, detail.text)
|
||||||
|
timeline = detail.json()["timeline"]
|
||||||
|
event_types = [event["event_type"] for event in timeline]
|
||||||
|
for required in (
|
||||||
|
"ARTICLE_CREATED",
|
||||||
|
"BOUNDARY_QUESTIONS_GENERATED",
|
||||||
|
"BOUNDARY_ANSWERS_SUBMITTED",
|
||||||
|
"PLAN_APPROVED",
|
||||||
|
"EVIDENCE_MATRIX_READY",
|
||||||
|
"PARALLEL_PRODUCTION_STARTED",
|
||||||
|
"AGENT_JOB_FAILED",
|
||||||
|
"AGENT_JOB_RETRIED",
|
||||||
|
"DRAFT_ASSEMBLED",
|
||||||
|
"SEO_REVIEW_COMPLETED",
|
||||||
|
"LANGUAGE_REVIEW_COMPLETED",
|
||||||
|
"FINAL_APPROVAL_GRANTED",
|
||||||
|
"PUBLISH_DRY_RUN_SUCCEEDED",
|
||||||
|
"PUBLISH_COMMIT_CREATED",
|
||||||
|
):
|
||||||
|
self.assertIn(required, event_types)
|
||||||
|
|
||||||
|
def _git_show(self, commit_sha: str, relative_path: str) -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "--git-dir", str(self.demo_repo_path), "show", f"{commit_sha}:{relative_path}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr or result.stdout)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||||
|
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||||
|
from src.presentation.dependencies import get_repository # noqa: E402
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceMatrixPublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects")
|
||||||
|
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'evidence.db'}"
|
||||||
|
self.repository = open_backend_repository(dsn)
|
||||||
|
self.repository.setup()
|
||||||
|
seed_reference_data(self.repository)
|
||||||
|
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
|
||||||
|
self.tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_insufficient_evidence_returns_to_plan_revision_and_blocks_draft(self) -> None:
|
||||||
|
article_id = self._create_researched_article("insufficient evidence coverage")
|
||||||
|
|
||||||
|
evidence_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/evidence",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
|
||||||
|
body = evidence_response.json()
|
||||||
|
self.assertEqual("PLAN_REVISION_REQUIRED", body["article"]["status"])
|
||||||
|
self.assertTrue(body["insufficient_evidence_reasons"])
|
||||||
|
|
||||||
|
unsupported = [
|
||||||
|
claim
|
||||||
|
for claim in body["claims"]
|
||||||
|
if claim["support_status"] == "UNSUPPORTED"
|
||||||
|
]
|
||||||
|
self.assertTrue(unsupported)
|
||||||
|
self.assertTrue(
|
||||||
|
any(claim["risk_level"] == "high" for claim in unsupported)
|
||||||
|
)
|
||||||
|
|
||||||
|
draft_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(409, draft_response.status_code, draft_response.text)
|
||||||
|
|
||||||
|
final_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/final/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(409, final_response.status_code, final_response.text)
|
||||||
|
|
||||||
|
evidence_item = body["evidence"][0]
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/evidence/{evidence_item['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"review_status": "REJECTED"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
self.assertEqual("REJECTED", patch_response.json()["evidence"]["review_status"])
|
||||||
|
|
||||||
|
def test_sufficient_evidence_maps_claims_and_allows_draft(self) -> None:
|
||||||
|
article_id = self._create_researched_article("sufficient evidence coverage")
|
||||||
|
|
||||||
|
evidence_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/evidence",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
|
||||||
|
body = evidence_response.json()
|
||||||
|
self.assertEqual("EVIDENCE_MATRIX_READY", body["article"]["status"])
|
||||||
|
self.assertFalse(body["insufficient_evidence_reasons"])
|
||||||
|
self.assertTrue(body["evidence"])
|
||||||
|
self.assertTrue(body["claims"])
|
||||||
|
self.assertTrue(
|
||||||
|
all(claim["evidence_item_ids"] for claim in body["claims"])
|
||||||
|
)
|
||||||
|
|
||||||
|
first_evidence = body["evidence"][0]
|
||||||
|
self.assertTrue(first_evidence["source_url"])
|
||||||
|
self.assertTrue(first_evidence["source_title"])
|
||||||
|
self.assertTrue(first_evidence["source_type"])
|
||||||
|
self.assertTrue(first_evidence["summary"])
|
||||||
|
self.assertGreaterEqual(first_evidence["source_quality_score"], 0)
|
||||||
|
self.assertTrue(first_evidence["retrieved_at"])
|
||||||
|
self.assertTrue(first_evidence["artifact_manifest_id"])
|
||||||
|
|
||||||
|
add_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/evidence",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"source_title": "Manual source",
|
||||||
|
"source_url": "https://manual.example/source",
|
||||||
|
"source_type": "manual",
|
||||||
|
"summary": "Manual evidence note",
|
||||||
|
"source_quality_score": 0.74,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, add_response.status_code, add_response.text)
|
||||||
|
manual_evidence = add_response.json()["evidence"]
|
||||||
|
self.assertEqual("PENDING", manual_evidence["review_status"])
|
||||||
|
|
||||||
|
delete_response = self.client.delete(
|
||||||
|
f"/api/articles/{article_id}/evidence/{manual_evidence['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, delete_response.status_code, delete_response.text)
|
||||||
|
|
||||||
|
refreshed = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/evidence",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()
|
||||||
|
self.assertNotIn(
|
||||||
|
manual_evidence["id"],
|
||||||
|
[item["id"] for item in refreshed["evidence"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
draft_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(202, draft_response.status_code, draft_response.text)
|
||||||
|
|
||||||
|
def _create_researched_article(self, brief_suffix: str) -> str:
|
||||||
|
site = self.client.get(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()[0]["site"]
|
||||||
|
article_id = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"target_site_id": site["id"],
|
||||||
|
"brief_description": f"Build an article with {brief_suffix}.",
|
||||||
|
"working_title": "Evidence Matrix Article",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": brief_suffix,
|
||||||
|
},
|
||||||
|
).json()["article"]["id"]
|
||||||
|
questions = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()["questions"]
|
||||||
|
for question in questions:
|
||||||
|
if question["is_required"]:
|
||||||
|
self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"answer": f"Answer for {question['category']}"},
|
||||||
|
)
|
||||||
|
self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
plan = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plan/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()["plan"]
|
||||||
|
self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.client.post(
|
||||||
|
f"/api/articles/{article_id}/research/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
return article_id
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||||
|
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||||
|
from src.presentation.dependencies import get_repository # noqa: E402
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||||
|
DEMO_ADMIN_EMAIL = "admin@example.com"
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class FinalApprovalGatePublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects")
|
||||||
|
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'final-approval-gate.db'}"
|
||||||
|
self.repository = open_backend_repository(dsn)
|
||||||
|
self.repository.setup()
|
||||||
|
seed_reference_data(self.repository)
|
||||||
|
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
|
||||||
|
self.tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_high_risk_unsupported_claims_block_final_approval(self) -> None:
|
||||||
|
article_id, draft = self._prepare_article_ready_for_final_approval(
|
||||||
|
include_high_risk_unsupported=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/final-approval",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json=self._final_approval_payload(draft_version=draft["version"]),
|
||||||
|
)
|
||||||
|
self.assertEqual(409, response.status_code, response.text)
|
||||||
|
self.assertIn("high-risk unsupported claims", response.text.lower())
|
||||||
|
|
||||||
|
def test_final_approval_requires_exact_draft_version(self) -> None:
|
||||||
|
article_id, draft = self._prepare_article_ready_for_final_approval()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/final-approval",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json=self._final_approval_payload(draft_version=draft["version"] + 1),
|
||||||
|
)
|
||||||
|
self.assertEqual(409, response.status_code, response.text)
|
||||||
|
self.assertIn("latest draft version", response.text.lower())
|
||||||
|
|
||||||
|
def test_missing_required_publishing_settings_block_final_approval(self) -> None:
|
||||||
|
article_id, draft = self._prepare_article_ready_for_final_approval()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/final-approval",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"draft_version": draft["version"],
|
||||||
|
"publishing_settings": {
|
||||||
|
"content_path": "",
|
||||||
|
"author": "",
|
||||||
|
"publishing_mode": "",
|
||||||
|
"frontmatter": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(409, response.status_code, response.text)
|
||||||
|
self.assertIn("required publishing settings are missing", response.text.lower())
|
||||||
|
|
||||||
|
def test_unapproved_required_assets_block_final_approval(self) -> None:
|
||||||
|
article_id, draft = self._prepare_article_ready_for_final_approval(approve_assets=False)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/final-approval",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json=self._final_approval_payload(draft_version=draft["version"]),
|
||||||
|
)
|
||||||
|
self.assertEqual(409, response.status_code, response.text)
|
||||||
|
self.assertIn("required assets", response.text.lower())
|
||||||
|
|
||||||
|
def test_successful_final_approval_writes_event_and_moves_to_publish_dry_run_required(self) -> None:
|
||||||
|
article_id, draft = self._prepare_article_ready_for_final_approval()
|
||||||
|
actor = self.client.get(
|
||||||
|
"/api/me",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()["user"]
|
||||||
|
payload = self._final_approval_payload(draft_version=draft["version"])
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/final-approval",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
body = response.json()
|
||||||
|
self.assertEqual("PUBLISH_DRY_RUN_REQUIRED", body["article"]["status"])
|
||||||
|
self.assertTrue(body["checklist"]["can_approve"])
|
||||||
|
self.assertFalse(body["checklist"]["blockers"])
|
||||||
|
|
||||||
|
detail = self.client.get(
|
||||||
|
f"/api/articles/{article_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()
|
||||||
|
events = [
|
||||||
|
event
|
||||||
|
for event in detail["workflow_events"]
|
||||||
|
if event["event_type"] == "FINAL_APPROVAL_GRANTED"
|
||||||
|
]
|
||||||
|
self.assertTrue(events, "Expected FINAL_APPROVAL_GRANTED event")
|
||||||
|
latest = events[-1]
|
||||||
|
self.assertEqual(actor["id"], latest["actor_user_id"])
|
||||||
|
self.assertTrue(latest["created_at"])
|
||||||
|
self.assertEqual(draft["version"], latest["payload"]["draft_version"])
|
||||||
|
self.assertEqual(payload["publishing_settings"], latest["payload"]["publishing_settings"])
|
||||||
|
|
||||||
|
def test_revision_request_writes_event_and_moves_article_to_final_revision_required(self) -> None:
|
||||||
|
article_id, draft = self._prepare_article_ready_for_final_approval()
|
||||||
|
actor = self.client.get(
|
||||||
|
"/api/me",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()["user"]
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/final-revision-request",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"reason": "Need factual clarification in section 2.",
|
||||||
|
"draft_version": draft["version"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
body = response.json()
|
||||||
|
self.assertEqual("FINAL_REVISION_REQUIRED", body["article"]["status"])
|
||||||
|
|
||||||
|
detail = self.client.get(
|
||||||
|
f"/api/articles/{article_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()
|
||||||
|
events = [
|
||||||
|
event
|
||||||
|
for event in detail["workflow_events"]
|
||||||
|
if event["event_type"] == "FINAL_REVISION_REQUESTED"
|
||||||
|
]
|
||||||
|
self.assertTrue(events, "Expected FINAL_REVISION_REQUESTED event")
|
||||||
|
latest = events[-1]
|
||||||
|
self.assertEqual(actor["id"], latest["actor_user_id"])
|
||||||
|
self.assertEqual("Need factual clarification in section 2.", latest["payload"]["reason"])
|
||||||
|
self.assertEqual(draft["version"], latest["payload"]["draft_version"])
|
||||||
|
|
||||||
|
def test_checklist_endpoint_returns_blockers_and_completed_items(self) -> None:
|
||||||
|
article_id, _ = self._prepare_article_ready_for_final_approval(approve_assets=False)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/final-approval/checklist",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
checklist = response.json()
|
||||||
|
self.assertFalse(checklist["can_approve"])
|
||||||
|
self.assertTrue(checklist["blockers"])
|
||||||
|
self.assertTrue(checklist["completed_items"])
|
||||||
|
self.assertTrue(any(item["completed"] for item in checklist["items"]))
|
||||||
|
self.assertTrue(any(not item["completed"] for item in checklist["items"]))
|
||||||
|
|
||||||
|
def _final_approval_payload(self, *, draft_version: int) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"draft_version": draft_version,
|
||||||
|
"publishing_settings": {
|
||||||
|
"content_path": "/guides/final-approval-gate",
|
||||||
|
"author": "Editorial Team",
|
||||||
|
"publishing_mode": "MANUAL",
|
||||||
|
"frontmatter": {
|
||||||
|
"category": "Guides",
|
||||||
|
"tags": ["workflow", "approval"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _prepare_article_ready_for_final_approval(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
include_high_risk_unsupported: bool = False,
|
||||||
|
approve_assets: bool = True,
|
||||||
|
) -> tuple[str, dict[str, Any]]:
|
||||||
|
article_id = self._create_article_with_approved_plan()
|
||||||
|
self._ensure_evidence_ready(article_id)
|
||||||
|
self._approve_all_evidence(article_id)
|
||||||
|
|
||||||
|
section_jobs = self._start_parallel_production(article_id)
|
||||||
|
self.assertGreaterEqual(len(section_jobs), 1)
|
||||||
|
first_job = section_jobs[0]
|
||||||
|
|
||||||
|
if include_high_risk_unsupported:
|
||||||
|
failed = self._complete_job(
|
||||||
|
first_job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": "outputs/high-risk.md"}],
|
||||||
|
"payload": {
|
||||||
|
"used_evidence_ids": first_job["payload"]["used_evidence_ids"],
|
||||||
|
"unsupported_claims": [
|
||||||
|
{
|
||||||
|
"claim_text": "Unverified migration benchmark for market leadership.",
|
||||||
|
"risk_level": "high",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"draft_markdown": "Section draft with high-risk unsupported claim.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("FAILED", failed["status"])
|
||||||
|
retry_response = self.client.post(
|
||||||
|
f"/api/agent-jobs/{first_job['id']}/retry",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, retry_response.status_code, retry_response.text)
|
||||||
|
retry_job = retry_response.json()["job"]
|
||||||
|
retried = self._complete_job(
|
||||||
|
retry_job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": "outputs/retry-clean.md"}],
|
||||||
|
"payload": {
|
||||||
|
"used_evidence_ids": retry_job["payload"]["used_evidence_ids"],
|
||||||
|
"unsupported_claims": [],
|
||||||
|
"draft_markdown": (
|
||||||
|
f"## {retry_job['payload']['heading']}\n\n"
|
||||||
|
"Clean retry section with [internal guide](/guides/internal)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("SUCCEEDED", retried["status"])
|
||||||
|
else:
|
||||||
|
completed = self._complete_job(
|
||||||
|
first_job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": "outputs/section-1.md"}],
|
||||||
|
"payload": {
|
||||||
|
"used_evidence_ids": first_job["payload"]["used_evidence_ids"],
|
||||||
|
"unsupported_claims": [],
|
||||||
|
"draft_markdown": (
|
||||||
|
f"## {first_job['payload']['heading']}\n\n"
|
||||||
|
"Use [internal guide](/guides/internal) for implementation details."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("SUCCEEDED", completed["status"])
|
||||||
|
|
||||||
|
for index, job in enumerate(section_jobs[1:], start=2):
|
||||||
|
completed = self._complete_job(
|
||||||
|
job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": f"outputs/section-{index}.md"}],
|
||||||
|
"payload": {
|
||||||
|
"used_evidence_ids": job["payload"]["used_evidence_ids"],
|
||||||
|
"unsupported_claims": [],
|
||||||
|
"draft_markdown": f"## {job['payload']['heading']}\n\nSection {index} ready.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("SUCCEEDED", completed["status"])
|
||||||
|
|
||||||
|
assemble_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/assemble",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, assemble_response.status_code, assemble_response.text)
|
||||||
|
draft = assemble_response.json()["draft"]
|
||||||
|
|
||||||
|
assets_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/generate-specs",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, assets_response.status_code, assets_response.text)
|
||||||
|
assets = assets_response.json()["assets"]
|
||||||
|
self.assertTrue(assets)
|
||||||
|
|
||||||
|
if approve_assets:
|
||||||
|
for asset in assets:
|
||||||
|
upload_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/{asset['id']}/upload",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"filename": f"{asset['id']}.png",
|
||||||
|
"content_base64": base64.b64encode(b"asset-binary").decode("utf-8"),
|
||||||
|
"content_type": "image/png",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, upload_response.status_code, upload_response.text)
|
||||||
|
approve_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/{asset['id']}/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, approve_response.status_code, approve_response.text)
|
||||||
|
|
||||||
|
return article_id, draft
|
||||||
|
|
||||||
|
def _approve_all_evidence(self, article_id: str) -> None:
|
||||||
|
evidence_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/evidence",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
|
||||||
|
evidence = evidence_response.json()["evidence"]
|
||||||
|
self.assertTrue(evidence)
|
||||||
|
for item in evidence:
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/evidence/{item['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"review_status": "APPROVED"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
|
||||||
|
def _complete_job(self, job_id: str, *, output: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/internal/agent-jobs/{job_id}/complete",
|
||||||
|
json={
|
||||||
|
"workspace_path": f"/tmp/{job_id}",
|
||||||
|
"stdout": "fake section scaffolding runner\n",
|
||||||
|
"stderr": "",
|
||||||
|
"exit_code": 0,
|
||||||
|
"duration_ms": 1,
|
||||||
|
"output": output,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
return response.json()["job"]
|
||||||
|
|
||||||
|
def _start_parallel_production(self, article_id: str) -> list[dict[str, Any]]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(202, response.status_code, response.text)
|
||||||
|
return [job for job in response.json()["jobs"] if job["job_type"] == "SECTION_SCAFFOLD"]
|
||||||
|
|
||||||
|
def _ensure_evidence_ready(self, article_id: str) -> None:
|
||||||
|
research_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/research/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, research_response.status_code, research_response.text)
|
||||||
|
|
||||||
|
evidence_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/evidence",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
|
||||||
|
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
|
||||||
|
|
||||||
|
def _create_article_with_approved_plan(self) -> str:
|
||||||
|
site = self.client.get(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()[0]["site"]
|
||||||
|
article = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"target_site_id": site["id"],
|
||||||
|
"brief_description": "Validate final approval gate checklist and transitions.",
|
||||||
|
"working_title": "Final Approval Gate",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "final approval gate",
|
||||||
|
},
|
||||||
|
).json()["article"]
|
||||||
|
article_id = article["id"]
|
||||||
|
|
||||||
|
questions = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()["questions"]
|
||||||
|
for question in questions:
|
||||||
|
if question["is_required"]:
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"answer": f"Answer for {question['category']}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
|
||||||
|
submit_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, submit_response.status_code, submit_response.text)
|
||||||
|
|
||||||
|
plan_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plan/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, plan_response.status_code, plan_response.text)
|
||||||
|
plan = plan_response.json()["plan"]
|
||||||
|
self.assertGreaterEqual(len(plan["sections"]), 1)
|
||||||
|
|
||||||
|
approve_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, approve_response.status_code, approve_response.text)
|
||||||
|
return article_id
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||||
|
from src.domain.contracts import AgentJobStatus, AgentJobType, PublishingStatus # noqa: E402
|
||||||
|
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||||
|
from src.presentation.dependencies import get_repository # noqa: E402
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||||
|
DEMO_ADMIN_EMAIL = "admin@example.com"
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class ObservabilityRetryCancelAuditPublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects")
|
||||||
|
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'observability.db'}"
|
||||||
|
self.repository = open_backend_repository(dsn)
|
||||||
|
self.repository.setup()
|
||||||
|
seed_reference_data(self.repository)
|
||||||
|
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
|
||||||
|
self.tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_failed_job_appears_in_article_history_with_retry_eligibility(self) -> None:
|
||||||
|
article_id = self._prepare_article_for_parallel_production()
|
||||||
|
created_jobs = self._start_parallel_production(article_id)
|
||||||
|
section_job = [job for job in created_jobs if job["job_type"] == "SECTION_SCAFFOLD"][0]
|
||||||
|
|
||||||
|
failed_job = self._complete_job(
|
||||||
|
section_job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": "outputs/section-failed.md"}],
|
||||||
|
"payload": {
|
||||||
|
"unsupported_claims": [
|
||||||
|
{"claim_text": "Unverified claim", "risk_level": "high"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("FAILED", failed_job["status"])
|
||||||
|
|
||||||
|
detail_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, detail_response.status_code, detail_response.text)
|
||||||
|
article_jobs = detail_response.json()["agent_jobs"]
|
||||||
|
history_failed_job = [job for job in article_jobs if job["id"] == failed_job["id"]][0]
|
||||||
|
self.assertIn("retry_eligible", history_failed_job)
|
||||||
|
self.assertTrue(history_failed_job["retry_eligible"])
|
||||||
|
self.assertIsNone(history_failed_job["retry_block_reason"])
|
||||||
|
|
||||||
|
def test_article_timeline_mixes_user_system_and_agent_events_in_order(self) -> None:
|
||||||
|
article_id = self._prepare_article_for_parallel_production()
|
||||||
|
section_job = self._start_parallel_production(article_id)[0]
|
||||||
|
failed = self._complete_job(
|
||||||
|
section_job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": "outputs/section-failed.md"}],
|
||||||
|
"payload": {
|
||||||
|
"unsupported_claims": [{"claim_text": "unsupported claim", "risk_level": "high"}]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("FAILED", failed["status"])
|
||||||
|
|
||||||
|
retry_response = self.client.post(
|
||||||
|
f"/api/agent-jobs/{failed['id']}/retry",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, retry_response.status_code, retry_response.text)
|
||||||
|
|
||||||
|
detail_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, detail_response.status_code, detail_response.text)
|
||||||
|
timeline = detail_response.json()["timeline"]
|
||||||
|
self.assertGreaterEqual(len(timeline), 3)
|
||||||
|
|
||||||
|
created_at_values = [entry["created_at"] for entry in timeline]
|
||||||
|
self.assertEqual(created_at_values, sorted(created_at_values))
|
||||||
|
|
||||||
|
sources = {entry["source"] for entry in timeline}
|
||||||
|
self.assertIn("USER", sources)
|
||||||
|
self.assertIn("SYSTEM", sources)
|
||||||
|
self.assertIn("AGENT", sources)
|
||||||
|
|
||||||
|
failed_job_entry = [
|
||||||
|
entry
|
||||||
|
for entry in timeline
|
||||||
|
if entry["entry_type"] == "AGENT_JOB"
|
||||||
|
and entry.get("job_id") == failed["id"]
|
||||||
|
][0]
|
||||||
|
self.assertTrue(failed_job_entry["retry_eligible"])
|
||||||
|
self.assertEqual("FAILED", failed_job_entry["job_status"])
|
||||||
|
|
||||||
|
def test_sensitive_logs_are_redacted_and_editor_sees_only_safe_failure_summary(self) -> None:
|
||||||
|
article_id = self._prepare_article_for_parallel_production()
|
||||||
|
section_job = self._start_parallel_production(article_id)[0]
|
||||||
|
|
||||||
|
failed_job = self._complete_job(
|
||||||
|
section_job["id"],
|
||||||
|
exit_code=1,
|
||||||
|
stdout=(
|
||||||
|
"token=topsecret123 password=hunter2 "
|
||||||
|
"Authorization: Bearer very-secret-token\n"
|
||||||
|
),
|
||||||
|
stderr="api_key=prod-key-123\n",
|
||||||
|
output={
|
||||||
|
"status": "FAILED",
|
||||||
|
"error_message": "runner failed with sk-1234567890abcdef",
|
||||||
|
"payload": {"last_successful_step": "outline written"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("FAILED", failed_job["status"])
|
||||||
|
|
||||||
|
admin_detail = self.client.get(
|
||||||
|
f"/api/articles/{article_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
).json()
|
||||||
|
editor_detail = self.client.get(
|
||||||
|
f"/api/articles/{article_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
admin_job = [job for job in admin_detail["agent_jobs"] if job["id"] == failed_job["id"]][0]
|
||||||
|
editor_job = [job for job in editor_detail["agent_jobs"] if job["id"] == failed_job["id"]][0]
|
||||||
|
|
||||||
|
self.assertIn("[REDACTED]", admin_job["stdout"])
|
||||||
|
self.assertIn("[REDACTED]", admin_job["stderr"])
|
||||||
|
self.assertNotIn("topsecret123", admin_job["stdout"])
|
||||||
|
self.assertNotIn("hunter2", admin_job["stdout"])
|
||||||
|
self.assertNotIn("very-secret-token", admin_job["stdout"])
|
||||||
|
self.assertNotIn("prod-key-123", admin_job["stderr"])
|
||||||
|
self.assertNotIn("sk-1234567890abcdef", admin_job["error_message"] or "")
|
||||||
|
|
||||||
|
self.assertEqual("", editor_job["stdout"])
|
||||||
|
self.assertEqual("", editor_job["stderr"])
|
||||||
|
self.assertIn("failed", editor_job["safe_failure_summary"].lower())
|
||||||
|
self.assertNotIn("topsecret123", editor_job["safe_failure_summary"])
|
||||||
|
|
||||||
|
def test_retry_eligibility_and_cancel_eligibility_follow_policy(self) -> None:
|
||||||
|
article_id = self._prepare_article_for_parallel_production()
|
||||||
|
jobs = self._start_parallel_production(article_id)
|
||||||
|
section_job = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"][0]
|
||||||
|
|
||||||
|
section_failed = self._complete_job(
|
||||||
|
section_job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": "outputs/section-failed.md"}],
|
||||||
|
"payload": {
|
||||||
|
"unsupported_claims": [{"claim_text": "unsupported claim", "risk_level": "high"}]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("FAILED", section_failed["status"])
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
evidence_job = self.repository.agent_jobs.create(
|
||||||
|
article_id=UUID(article_id),
|
||||||
|
parent_job_id=None,
|
||||||
|
attempt=1,
|
||||||
|
job_type=AgentJobType.EVIDENCE_MATRIX,
|
||||||
|
agent_profile="manual-test",
|
||||||
|
status=AgentJobStatus.QUEUED,
|
||||||
|
input_files=[{"path": "inputs/evidence.json"}],
|
||||||
|
queued_at=now,
|
||||||
|
)
|
||||||
|
evidence_failed = self.repository.agent_jobs.complete(
|
||||||
|
job_id=evidence_job.id,
|
||||||
|
status=AgentJobStatus.FAILED,
|
||||||
|
workspace_path="/tmp/evidence",
|
||||||
|
output_files=[],
|
||||||
|
payload={"phase": "collect"},
|
||||||
|
error_category=None,
|
||||||
|
error_message="temporary failure",
|
||||||
|
stdout="",
|
||||||
|
stderr="",
|
||||||
|
exit_code=1,
|
||||||
|
duration_ms=2,
|
||||||
|
finished_at=now,
|
||||||
|
)
|
||||||
|
self.assertEqual(AgentJobStatus.FAILED, evidence_failed.status)
|
||||||
|
|
||||||
|
queued_section = [job for job in jobs if job["id"] != section_job["id"]][0]
|
||||||
|
detail = self.client.get(
|
||||||
|
f"/api/articles/{article_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
failed_section_view = [job for job in detail["agent_jobs"] if job["id"] == section_failed["id"]][0]
|
||||||
|
evidence_view = [job for job in detail["agent_jobs"] if job["id"] == str(evidence_failed.id)][0]
|
||||||
|
queued_view = [job for job in detail["agent_jobs"] if job["id"] == queued_section["id"]][0]
|
||||||
|
|
||||||
|
self.assertTrue(failed_section_view["retry_eligible"])
|
||||||
|
self.assertIsNone(failed_section_view["retry_block_reason"])
|
||||||
|
self.assertFalse(failed_section_view["cancel_eligible"])
|
||||||
|
|
||||||
|
self.assertFalse(evidence_view["retry_eligible"])
|
||||||
|
self.assertIn("not supported", evidence_view["retry_block_reason"].lower())
|
||||||
|
self.assertFalse(evidence_view["cancel_eligible"])
|
||||||
|
|
||||||
|
self.assertFalse(queued_view["retry_eligible"])
|
||||||
|
self.assertTrue(queued_view["cancel_eligible"])
|
||||||
|
|
||||||
|
def test_cancelling_running_or_queued_job_prevents_article_state_mutation(self) -> None:
|
||||||
|
article_id = self._prepare_article_for_parallel_production()
|
||||||
|
jobs = self._start_parallel_production(article_id)
|
||||||
|
first_job = jobs[0]
|
||||||
|
|
||||||
|
cancel_response = self.client.post(
|
||||||
|
f"/api/agent-jobs/{first_job['id']}/cancel",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, cancel_response.status_code, cancel_response.text)
|
||||||
|
self.assertEqual("CANCELLED", cancel_response.json()["job"]["status"])
|
||||||
|
|
||||||
|
complete_response = self.client.post(
|
||||||
|
f"/internal/agent-jobs/{first_job['id']}/complete",
|
||||||
|
json={
|
||||||
|
"workspace_path": f"/tmp/{first_job['id']}",
|
||||||
|
"stdout": "completion after cancellation\n",
|
||||||
|
"stderr": "",
|
||||||
|
"exit_code": 0,
|
||||||
|
"duration_ms": 2,
|
||||||
|
"output": {
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": "outputs/cancelled.md"}],
|
||||||
|
"payload": {"draft_markdown": "should never apply"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, complete_response.status_code, complete_response.text)
|
||||||
|
self.assertEqual("CANCELLED", complete_response.json()["job"]["status"])
|
||||||
|
|
||||||
|
detail = self.client.get(
|
||||||
|
f"/api/articles/{article_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()
|
||||||
|
self.assertEqual("PARALLEL_PRODUCTION_RUNNING", detail["article"]["status"])
|
||||||
|
cancelled_view = [job for job in detail["agent_jobs"] if job["id"] == first_job["id"]][0]
|
||||||
|
self.assertEqual("CANCELLED", cancelled_view["status"])
|
||||||
|
|
||||||
|
def test_publish_commit_retry_blocked_after_publish_commit_exists(self) -> None:
|
||||||
|
article = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"target_site_id": self._first_site_id(),
|
||||||
|
"brief_description": "Publish retry policy test",
|
||||||
|
"working_title": "Publish Retry Policy",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "publish retry policy",
|
||||||
|
},
|
||||||
|
).json()["article"]
|
||||||
|
article_id = article["id"]
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
publish_job = self.repository.agent_jobs.create(
|
||||||
|
article_id=UUID(article_id),
|
||||||
|
parent_job_id=None,
|
||||||
|
attempt=1,
|
||||||
|
job_type=AgentJobType.PUBLISH_COMMIT,
|
||||||
|
agent_profile="manual-publish-commit",
|
||||||
|
status=AgentJobStatus.QUEUED,
|
||||||
|
input_files=[{"path": "inputs/publish.json"}],
|
||||||
|
queued_at=now,
|
||||||
|
)
|
||||||
|
publish_job = self.repository.agent_jobs.complete(
|
||||||
|
job_id=publish_job.id,
|
||||||
|
status=AgentJobStatus.FAILED,
|
||||||
|
workspace_path="/tmp/publish",
|
||||||
|
output_files=[],
|
||||||
|
payload={"stage": "git push"},
|
||||||
|
error_category=None,
|
||||||
|
error_message="git push failed",
|
||||||
|
stdout="",
|
||||||
|
stderr="",
|
||||||
|
exit_code=1,
|
||||||
|
duration_ms=5,
|
||||||
|
finished_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.repository.publish_commits.create(
|
||||||
|
article_id=UUID(article_id),
|
||||||
|
target_site_id=UUID(self._first_site_id()),
|
||||||
|
repository_url="git@example.com/site.git",
|
||||||
|
branch="main",
|
||||||
|
commit_sha="abc123",
|
||||||
|
content_bundle_manifest={"validation": {"label": "ok"}},
|
||||||
|
status=PublishingStatus.PUBLISH_COMMIT_CREATED,
|
||||||
|
deployment_status=None,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
retry_response = self.client.post(
|
||||||
|
f"/api/agent-jobs/{publish_job.id}/retry",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(409, retry_response.status_code, retry_response.text)
|
||||||
|
self.assertIn("already exists", retry_response.text.lower())
|
||||||
|
|
||||||
|
detail = self.client.get(
|
||||||
|
f"/api/articles/{article_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
).json()
|
||||||
|
publish_job_view = [job for job in detail["agent_jobs"] if job["id"] == str(publish_job.id)][0]
|
||||||
|
self.assertFalse(publish_job_view["retry_eligible"])
|
||||||
|
self.assertIn("already exists", publish_job_view["retry_block_reason"].lower())
|
||||||
|
|
||||||
|
def test_script_config_changes_are_audit_visible_with_diff_and_rollback_target(self) -> None:
|
||||||
|
site_id = self._first_site_id()
|
||||||
|
first = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json={
|
||||||
|
"publishing_yaml": "target: demo\n",
|
||||||
|
"transform_script": "export function transformArticle(a){return a;}\n",
|
||||||
|
"diff": {"summary": "v1 create"},
|
||||||
|
"activate": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, first.status_code, first.text)
|
||||||
|
first_version = first.json()["version"]
|
||||||
|
|
||||||
|
second = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json={
|
||||||
|
"publishing_yaml": "target: demo\nbranch: main\n",
|
||||||
|
"transform_script": "export function transformArticle(a){return {...a, changed:true};}\n",
|
||||||
|
"diff": {"summary": "v2 create"},
|
||||||
|
"rollback_target_version_id": first_version["id"],
|
||||||
|
"activate": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, second.status_code, second.text)
|
||||||
|
second_version = second.json()["version"]
|
||||||
|
|
||||||
|
rollback = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions/{first_version['id']}/rollback",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, rollback.status_code, rollback.text)
|
||||||
|
|
||||||
|
audit_response = self.client.get(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/audit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, audit_response.status_code, audit_response.text)
|
||||||
|
events = audit_response.json()["events"]
|
||||||
|
self.assertGreaterEqual(len(events), 3)
|
||||||
|
for event in events:
|
||||||
|
self.assertIn("diff", event)
|
||||||
|
self.assertIn("rollback_target_version_id", event)
|
||||||
|
|
||||||
|
rollback_events = [event for event in events if event["event_type"] == "SCRIPT_CONFIG_VERSION_ROLLBACK"]
|
||||||
|
self.assertEqual(1, len(rollback_events))
|
||||||
|
rollback_event = rollback_events[0]
|
||||||
|
self.assertEqual(second_version["id"], rollback_event["rollback_target_version_id"])
|
||||||
|
|
||||||
|
def _start_parallel_production(self, article_id: str) -> list[dict[str, Any]]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(202, response.status_code, response.text)
|
||||||
|
return response.json()["jobs"]
|
||||||
|
|
||||||
|
def _complete_job(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
output: dict[str, Any],
|
||||||
|
stdout: str = "fake section scaffolding runner\n",
|
||||||
|
stderr: str = "",
|
||||||
|
exit_code: int = 0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/internal/agent-jobs/{job_id}/complete",
|
||||||
|
json={
|
||||||
|
"workspace_path": f"/tmp/{job_id}",
|
||||||
|
"stdout": stdout,
|
||||||
|
"stderr": stderr,
|
||||||
|
"exit_code": exit_code,
|
||||||
|
"duration_ms": 2,
|
||||||
|
"output": output,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
return response.json()["job"]
|
||||||
|
|
||||||
|
def _first_site_id(self) -> str:
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
return response.json()[0]["site"]["id"]
|
||||||
|
|
||||||
|
def _prepare_article_for_parallel_production(self) -> str:
|
||||||
|
article_id = self._create_article_with_approved_plan()
|
||||||
|
research_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/research/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, research_response.status_code, research_response.text)
|
||||||
|
|
||||||
|
evidence_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/evidence",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
|
||||||
|
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
|
||||||
|
return article_id
|
||||||
|
|
||||||
|
def _create_article_with_approved_plan(self) -> str:
|
||||||
|
article_response = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"target_site_id": self._first_site_id(),
|
||||||
|
"brief_description": "Observe failed job history and retry eligibility.",
|
||||||
|
"working_title": "Observability Retry Audit",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "observability retry audit",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, article_response.status_code, article_response.text)
|
||||||
|
article_id = article_response.json()["article"]["id"]
|
||||||
|
|
||||||
|
questions = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()["questions"]
|
||||||
|
for question in questions:
|
||||||
|
if question["is_required"]:
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"answer": f"Answer for {question['category']}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
|
||||||
|
submit_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, submit_response.status_code, submit_response.text)
|
||||||
|
|
||||||
|
plan_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plan/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, plan_response.status_code, plan_response.text)
|
||||||
|
plan = plan_response.json()["plan"]
|
||||||
|
|
||||||
|
approve_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, approve_response.status_code, approve_response.text)
|
||||||
|
return article_id
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||||
|
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||||
|
from src.presentation.dependencies import get_repository # noqa: E402
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||||
|
DEMO_ADMIN_EMAIL = "admin@example.com"
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class ParallelProductionPublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects")
|
||||||
|
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'parallel-production.db'}"
|
||||||
|
self.repository = open_backend_repository(dsn)
|
||||||
|
self.repository.setup()
|
||||||
|
seed_reference_data(self.repository)
|
||||||
|
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
|
||||||
|
self.tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_start_production_requires_evidence_matrix_ready(self) -> None:
|
||||||
|
article_id, _ = self._create_article_with_approved_plan()
|
||||||
|
research_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/research/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, research_response.status_code, research_response.text)
|
||||||
|
|
||||||
|
start_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(409, start_response.status_code, start_response.text)
|
||||||
|
self.assertIn("Evidence matrix must be ready", start_response.text)
|
||||||
|
|
||||||
|
def test_start_production_creates_one_section_job_per_approved_plan_section(self) -> None:
|
||||||
|
article_id, approved_section_count = self._prepare_article_for_parallel_production()
|
||||||
|
|
||||||
|
start_production_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(202, start_production_response.status_code, start_production_response.text)
|
||||||
|
|
||||||
|
jobs = start_production_response.json()["jobs"]
|
||||||
|
section_jobs = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"]
|
||||||
|
self.assertEqual(
|
||||||
|
approved_section_count,
|
||||||
|
len(section_jobs),
|
||||||
|
"Expected one SECTION_SCAFFOLD job per approved plan section",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_section_jobs_run_independently_and_can_fail_independently(self) -> None:
|
||||||
|
article_id, _ = self._prepare_article_for_parallel_production()
|
||||||
|
created_jobs = self._start_parallel_production(article_id)
|
||||||
|
section_jobs = [job for job in created_jobs if job["job_type"] == "SECTION_SCAFFOLD"]
|
||||||
|
self.assertGreaterEqual(len(section_jobs), 2)
|
||||||
|
|
||||||
|
success_job = section_jobs[0]
|
||||||
|
failed_job = section_jobs[1]
|
||||||
|
complete_success = self._complete_job(
|
||||||
|
success_job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": "outputs/section-success.md"}],
|
||||||
|
"payload": {
|
||||||
|
"used_evidence_ids": success_job["payload"]["used_evidence_ids"],
|
||||||
|
"unsupported_claims": [],
|
||||||
|
"draft_markdown": "Supported section draft",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("SUCCEEDED", complete_success["status"])
|
||||||
|
self.assertIsNone(complete_success["error_category"])
|
||||||
|
|
||||||
|
unsupported_claims = [
|
||||||
|
{
|
||||||
|
"claim_text": "Unverified benchmark introduced during scaffolding.",
|
||||||
|
"risk_level": "high",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
complete_failure = self._complete_job(
|
||||||
|
failed_job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": "outputs/section-failed.md"}],
|
||||||
|
"payload": {
|
||||||
|
"used_evidence_ids": failed_job["payload"]["used_evidence_ids"],
|
||||||
|
"unsupported_claims": unsupported_claims,
|
||||||
|
"draft_markdown": "Draft includes unsupported claim",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("FAILED", complete_failure["status"])
|
||||||
|
self.assertEqual("UNSUPPORTED_CLAIMS_FOUND", complete_failure["error_category"])
|
||||||
|
self.assertEqual(unsupported_claims, complete_failure["payload"]["unsupported_claims"])
|
||||||
|
|
||||||
|
def test_retry_failed_section_does_not_rerun_successful_sections(self) -> None:
|
||||||
|
article_id, _ = self._prepare_article_for_parallel_production()
|
||||||
|
created_jobs = self._start_parallel_production(article_id)
|
||||||
|
section_jobs = [job for job in created_jobs if job["job_type"] == "SECTION_SCAFFOLD"]
|
||||||
|
self.assertGreaterEqual(len(section_jobs), 2)
|
||||||
|
|
||||||
|
success_job = self._complete_job(
|
||||||
|
section_jobs[0]["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": "outputs/section-success.md"}],
|
||||||
|
"payload": {"unsupported_claims": []},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
failed_job = self._complete_job(
|
||||||
|
section_jobs[1]["id"],
|
||||||
|
output={
|
||||||
|
"status": "FAILED",
|
||||||
|
"error_category": "CLI_EXIT_CODE_FAILURE",
|
||||||
|
"error_message": "Runner failed for one section",
|
||||||
|
"payload": {"unsupported_claims": []},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
retry_response = self.client.post(
|
||||||
|
f"/api/agent-jobs/{failed_job['id']}/retry",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, retry_response.status_code, retry_response.text)
|
||||||
|
retry_job = retry_response.json()["job"]
|
||||||
|
self.assertEqual("QUEUED", retry_job["status"])
|
||||||
|
self.assertEqual(failed_job["id"], retry_job["parent_job_id"])
|
||||||
|
self.assertEqual(2, retry_job["attempt"])
|
||||||
|
|
||||||
|
detail = self.client.get(
|
||||||
|
f"/api/articles/{article_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()
|
||||||
|
successful_descendants = [
|
||||||
|
job
|
||||||
|
for job in detail["agent_jobs"]
|
||||||
|
if job.get("parent_job_id") == success_job["id"]
|
||||||
|
]
|
||||||
|
failed_descendants = [
|
||||||
|
job
|
||||||
|
for job in detail["agent_jobs"]
|
||||||
|
if job.get("parent_job_id") == failed_job["id"]
|
||||||
|
]
|
||||||
|
self.assertEqual([], successful_descendants)
|
||||||
|
self.assertEqual(1, len(failed_descendants))
|
||||||
|
self.assertEqual("QUEUED", failed_descendants[0]["status"])
|
||||||
|
|
||||||
|
def test_each_scaffold_payload_lists_used_evidence_ids(self) -> None:
|
||||||
|
article_id, _ = self._prepare_article_for_parallel_production()
|
||||||
|
evidence = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/evidence",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()["evidence"]
|
||||||
|
evidence_ids = {item["id"] for item in evidence}
|
||||||
|
|
||||||
|
jobs = self._start_parallel_production(article_id)
|
||||||
|
section_jobs = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"]
|
||||||
|
self.assertTrue(section_jobs)
|
||||||
|
self.assertTrue(any(job["payload"]["used_evidence_ids"] for job in section_jobs))
|
||||||
|
for job in section_jobs:
|
||||||
|
self.assertIn("used_evidence_ids", job["payload"])
|
||||||
|
self.assertIsInstance(job["payload"]["used_evidence_ids"], list)
|
||||||
|
for evidence_id in job["payload"]["used_evidence_ids"]:
|
||||||
|
self.assertIn(evidence_id, evidence_ids)
|
||||||
|
|
||||||
|
def test_unsupported_claims_from_scaffold_are_captured_in_article_detail(self) -> None:
|
||||||
|
article_id, _ = self._prepare_article_for_parallel_production()
|
||||||
|
jobs = self._start_parallel_production(article_id)
|
||||||
|
section_job = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"][0]
|
||||||
|
unsupported_claims = [
|
||||||
|
{
|
||||||
|
"claim_text": "Unverified migration timeline claim",
|
||||||
|
"risk_level": "medium",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
completed_job = self._complete_job(
|
||||||
|
section_job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": "outputs/section-with-unsupported.md"}],
|
||||||
|
"payload": {
|
||||||
|
"used_evidence_ids": section_job["payload"]["used_evidence_ids"],
|
||||||
|
"unsupported_claims": unsupported_claims,
|
||||||
|
"draft_markdown": "Draft with unsupported claim",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("FAILED", completed_job["status"])
|
||||||
|
self.assertEqual("UNSUPPORTED_CLAIMS_FOUND", completed_job["error_category"])
|
||||||
|
|
||||||
|
detail_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, detail_response.status_code, detail_response.text)
|
||||||
|
article_jobs = detail_response.json()["agent_jobs"]
|
||||||
|
refreshed_job = [job for job in article_jobs if job["id"] == section_job["id"]][0]
|
||||||
|
self.assertEqual(unsupported_claims, refreshed_job["payload"]["unsupported_claims"])
|
||||||
|
self.assertIn("Unsupported claims introduced during scaffolding", refreshed_job["error_message"])
|
||||||
|
|
||||||
|
def _start_parallel_production(self, article_id: str) -> list[dict[str, Any]]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(202, response.status_code, response.text)
|
||||||
|
return response.json()["jobs"]
|
||||||
|
|
||||||
|
def _complete_job(self, job_id: str, *, output: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/internal/agent-jobs/{job_id}/complete",
|
||||||
|
json={
|
||||||
|
"workspace_path": f"/tmp/{job_id}",
|
||||||
|
"stdout": "fake section scaffolding runner\n",
|
||||||
|
"stderr": "",
|
||||||
|
"exit_code": 0,
|
||||||
|
"duration_ms": 2,
|
||||||
|
"output": output,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
return response.json()["job"]
|
||||||
|
|
||||||
|
def _prepare_article_for_parallel_production(self) -> tuple[str, int]:
|
||||||
|
article_id, approved_section_count = self._create_article_with_approved_plan()
|
||||||
|
|
||||||
|
research_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/research/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, research_response.status_code, research_response.text)
|
||||||
|
|
||||||
|
evidence_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/evidence",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
|
||||||
|
self.assertFalse(evidence_response.json()["insufficient_evidence_reasons"])
|
||||||
|
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
|
||||||
|
return article_id, approved_section_count
|
||||||
|
|
||||||
|
def _create_article_with_approved_plan(self) -> tuple[str, int]:
|
||||||
|
site = self.client.get(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()[0]["site"]
|
||||||
|
article = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"target_site_id": site["id"],
|
||||||
|
"brief_description": "Run parallel production jobs from approved sections.",
|
||||||
|
"working_title": "Parallel Production Jobs",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "parallel production jobs",
|
||||||
|
},
|
||||||
|
).json()["article"]
|
||||||
|
article_id = article["id"]
|
||||||
|
|
||||||
|
questions = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()["questions"]
|
||||||
|
for question in questions:
|
||||||
|
if question["is_required"]:
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"answer": f"Answer for {question['category']}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
|
||||||
|
submit_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, submit_response.status_code, submit_response.text)
|
||||||
|
|
||||||
|
plan_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plan/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, plan_response.status_code, plan_response.text)
|
||||||
|
plan = plan_response.json()["plan"]
|
||||||
|
self.assertGreaterEqual(len(plan["sections"]), 1)
|
||||||
|
|
||||||
|
approve_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, approve_response.status_code, approve_response.text)
|
||||||
|
return article_id, len(plan["sections"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||||
|
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||||
|
from src.presentation.dependencies import get_repository # noqa: E402
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class PlanGenerationReviewPublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'plans.db'}"
|
||||||
|
self.repository = open_backend_repository(dsn)
|
||||||
|
self.repository.setup()
|
||||||
|
seed_reference_data(self.repository)
|
||||||
|
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
self.tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_research_start_is_blocked_until_plan_approval(self) -> None:
|
||||||
|
article_id = self._create_article_with_boundary_answers()
|
||||||
|
|
||||||
|
blocked_research_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/research/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(409, blocked_research_response.status_code, blocked_research_response.text)
|
||||||
|
|
||||||
|
generate_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plan/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, generate_response.status_code, generate_response.text)
|
||||||
|
plan = generate_response.json()["plan"]
|
||||||
|
self.assertEqual(1, plan["version"])
|
||||||
|
self.assertEqual("PENDING_REVIEW", plan["status"])
|
||||||
|
self.assertGreaterEqual(len(plan["sections"]), 4)
|
||||||
|
self.assertTrue(plan["recommended_title"])
|
||||||
|
self.assertTrue(plan["reader_persona"])
|
||||||
|
self.assertTrue(plan["evidence_needs"])
|
||||||
|
self.assertTrue(plan["visual_needs"])
|
||||||
|
for section in plan["sections"]:
|
||||||
|
self.assertTrue(section["purpose"])
|
||||||
|
self.assertTrue(section["key_points"])
|
||||||
|
self.assertTrue(section["evidence_needs"])
|
||||||
|
|
||||||
|
edit_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/plans/{plan['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"recommended_title": "Edited plan title",
|
||||||
|
"tone": "Direct and evidence-led",
|
||||||
|
"audience": "Technical editors",
|
||||||
|
"seo_notes": ["Prioritize comparison intent"],
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"heading": "Edited section",
|
||||||
|
"purpose": "Set the context",
|
||||||
|
"key_points": ["Point A", "Point B"],
|
||||||
|
"evidence_needs": ["Vendor docs"],
|
||||||
|
"target_word_count": 300,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, edit_response.status_code, edit_response.text)
|
||||||
|
edited_plan = edit_response.json()["plan"]
|
||||||
|
self.assertEqual(2, edited_plan["version"])
|
||||||
|
self.assertEqual("Edited plan title", edited_plan["recommended_title"])
|
||||||
|
|
||||||
|
list_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/plans",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, list_response.status_code, list_response.text)
|
||||||
|
self.assertEqual([1, 2], [item["version"] for item in list_response.json()["plans"]])
|
||||||
|
|
||||||
|
revision_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plans/{edited_plan['id']}/request-revision",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"notes": "Narrow the evidence standard."},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, revision_response.status_code, revision_response.text)
|
||||||
|
self.assertEqual("CHANGES_REQUESTED", revision_response.json()["review"]["status"])
|
||||||
|
|
||||||
|
approve_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plans/{edited_plan['id']}/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, approve_response.status_code, approve_response.text)
|
||||||
|
self.assertEqual("APPROVED", approve_response.json()["review"]["status"])
|
||||||
|
|
||||||
|
allowed_research_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/research/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, allowed_research_response.status_code, allowed_research_response.text)
|
||||||
|
self.assertEqual("RESEARCH", allowed_research_response.json()["job"]["job_type"])
|
||||||
|
|
||||||
|
detail_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
events = detail_response.json()["workflow_events"]
|
||||||
|
approval_events = [
|
||||||
|
event for event in events if event["event_type"] == "PLAN_APPROVED"
|
||||||
|
]
|
||||||
|
self.assertEqual(1, len(approval_events))
|
||||||
|
self.assertEqual(2, approval_events[0]["payload"]["plan_version"])
|
||||||
|
|
||||||
|
def _create_article_with_boundary_answers(self) -> str:
|
||||||
|
site_response = self.client.get(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, site_response.status_code, site_response.text)
|
||||||
|
target_site = site_response.json()[0]["site"]
|
||||||
|
|
||||||
|
create_response = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"target_site_id": target_site["id"],
|
||||||
|
"brief_description": "Create an implementation plan for AI content governance.",
|
||||||
|
"working_title": "AI Content Governance",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "AI content governance",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||||
|
article_id = create_response.json()["article"]["id"]
|
||||||
|
|
||||||
|
questions_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, questions_response.status_code, questions_response.text)
|
||||||
|
for question in questions_response.json()["questions"]:
|
||||||
|
if question["is_required"]:
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"answer": f"Answer for {question['category']}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
|
||||||
|
submit_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, submit_response.status_code, submit_response.text)
|
||||||
|
return article_id
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from importlib.util import find_spec
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||||
|
BACKEND_ROOT = REPO_ROOT / "apps" / "backend"
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresAlembicMigrationIntegrationTest(unittest.TestCase):
|
||||||
|
def test_alembic_upgrade_head_creates_schema_on_configured_postgres(self) -> None:
|
||||||
|
dsn = os.environ.get("PIPELINE_TEST_DATABASE_DSN")
|
||||||
|
if not dsn:
|
||||||
|
self.skipTest("PIPELINE_TEST_DATABASE_DSN is not set")
|
||||||
|
if not dsn.startswith(("postgresql://", "postgres://")):
|
||||||
|
self.skipTest("Postgres DSN is required for Alembic migration test")
|
||||||
|
if find_spec("alembic") is None:
|
||||||
|
self.skipTest("alembic is not installed")
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["DATABASE_URL"] = dsn
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
env=env,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
from src.domain.schema import CORE_TABLES
|
||||||
|
from src.infrastructure.repositories import open_backend_repository
|
||||||
|
|
||||||
|
repository = open_backend_repository(dsn)
|
||||||
|
self.assertTrue(set(CORE_TABLES).issubset(repository.schema.list_tables()))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||||
|
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||||
|
from src.presentation.dependencies import get_repository # noqa: E402
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||||
|
DEMO_ADMIN_EMAIL = "admin@example.com"
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class PublishingGitFlowPublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.objects_root = Path(self.tmp_dir.name) / "objects"
|
||||||
|
self.bare_repo_path = Path(self.tmp_dir.name) / "demo-site.git"
|
||||||
|
self.seed_repo_path = Path(self.tmp_dir.name) / "demo-site-seed"
|
||||||
|
|
||||||
|
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(self.objects_root)
|
||||||
|
self._prepare_local_bare_git_repo()
|
||||||
|
|
||||||
|
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'publishing-git-flow.db'}"
|
||||||
|
self.repository = open_backend_repository(dsn)
|
||||||
|
self.repository.setup()
|
||||||
|
seed_reference_data(self.repository)
|
||||||
|
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
|
||||||
|
self.tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_publishing_dry_run_blocked_before_final_approval(self) -> None:
|
||||||
|
article_id, _ = self._prepare_article_ready_for_final_approval()
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/publishing/dry-run",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(409, response.status_code, response.text)
|
||||||
|
self.assertIn("final approval", response.text.lower())
|
||||||
|
|
||||||
|
def test_publishing_dry_run_validation_failure_sets_failed_status(self) -> None:
|
||||||
|
article_id, draft = self._prepare_final_approved_article()
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/drafts/{draft['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"body_markdown": "Only plain text, no markdown heading present."},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/publishing/dry-run",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, response.status_code, response.text)
|
||||||
|
body = response.json()
|
||||||
|
self.assertFalse(body["content_shape_valid"])
|
||||||
|
self.assertIn("best-effort", body["validation_label"].lower())
|
||||||
|
self.assertIn("heading", " ".join(body["errors"]).lower())
|
||||||
|
self.assertEqual("PUBLISH_DRY_RUN_FAILED", body["article"]["publishing_status"])
|
||||||
|
self.assertEqual("PUBLISH_DRY_RUN_REQUIRED", body["article"]["status"])
|
||||||
|
|
||||||
|
def test_publish_commit_blocked_before_successful_dry_run(self) -> None:
|
||||||
|
article_id, _ = self._prepare_final_approved_article()
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/publishing/create-commit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(409, response.status_code, response.text)
|
||||||
|
self.assertIn("dry run", response.text.lower())
|
||||||
|
|
||||||
|
def test_final_approved_article_can_create_commit_in_local_git_repository(self) -> None:
|
||||||
|
article_id, _ = self._prepare_final_approved_article()
|
||||||
|
|
||||||
|
dry_run_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/publishing/dry-run",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, dry_run_response.status_code, dry_run_response.text)
|
||||||
|
self.assertTrue(dry_run_response.json()["content_shape_valid"])
|
||||||
|
self.assertEqual("PUBLISH_COMMIT_READY", dry_run_response.json()["article"]["status"])
|
||||||
|
|
||||||
|
commit_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/publishing/create-commit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, commit_response.status_code, commit_response.text)
|
||||||
|
body = commit_response.json()
|
||||||
|
commit = body["publish_commit"]
|
||||||
|
commit_sha = commit["commit_sha"]
|
||||||
|
self.assertTrue(commit_sha)
|
||||||
|
self.assertEqual(str(self.bare_repo_path), commit["repository_url"])
|
||||||
|
self.assertEqual("main", commit["branch"])
|
||||||
|
self.assertEqual("PUBLISH_COMMIT_CREATED", commit["status"])
|
||||||
|
|
||||||
|
check = subprocess.run(
|
||||||
|
["git", "--git-dir", str(self.bare_repo_path), "cat-file", "-e", f"{commit_sha}^{{commit}}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(0, check.returncode, check.stderr)
|
||||||
|
|
||||||
|
def test_non_fast_forward_conflict_fails_without_rebase(self) -> None:
|
||||||
|
article_id, _ = self._prepare_final_approved_article()
|
||||||
|
dry_run_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/publishing/dry-run",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, dry_run_response.status_code, dry_run_response.text)
|
||||||
|
self._push_remote_commit("competing remote update")
|
||||||
|
|
||||||
|
commit_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/publishing/create-commit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(409, commit_response.status_code, commit_response.text)
|
||||||
|
self.assertIn("non-fast-forward", commit_response.text.lower())
|
||||||
|
|
||||||
|
def test_publishing_dry_run_supports_s3_asset_urls_with_object_keys(self) -> None:
|
||||||
|
article_id, _ = self._prepare_final_approved_article()
|
||||||
|
self._rewrite_asset_urls_to_s3(article_id)
|
||||||
|
|
||||||
|
dry_run_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/publishing/dry-run",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, dry_run_response.status_code, dry_run_response.text)
|
||||||
|
body = dry_run_response.json()
|
||||||
|
self.assertTrue(body["content_shape_valid"])
|
||||||
|
self.assertEqual("PUBLISH_COMMIT_READY", body["article"]["status"])
|
||||||
|
self.assertEqual("PUBLISH_COMMIT_READY", body["article"]["publishing_status"])
|
||||||
|
|
||||||
|
def test_status_and_commits_endpoints_expose_required_metadata(self) -> None:
|
||||||
|
article_id, _ = self._prepare_final_approved_article()
|
||||||
|
|
||||||
|
dry_run_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/publishing/dry-run",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, dry_run_response.status_code, dry_run_response.text)
|
||||||
|
|
||||||
|
commit_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/publishing/create-commit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, commit_response.status_code, commit_response.text)
|
||||||
|
|
||||||
|
status_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/publishing/status",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, status_response.status_code, status_response.text)
|
||||||
|
status_body = status_response.json()
|
||||||
|
self.assertIn("best-effort", status_body["validation_label"].lower())
|
||||||
|
self.assertEqual("PUBLISH_COMMIT_CREATED", status_body["article"]["status"])
|
||||||
|
self.assertEqual("PUBLISH_COMMIT_CREATED", status_body["article"]["publishing_status"])
|
||||||
|
self.assertIsNotNone(status_body["latest_dry_run"])
|
||||||
|
self.assertIsNotNone(status_body["latest_publish_commit"])
|
||||||
|
|
||||||
|
commits_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/publishing/commits",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, commits_response.status_code, commits_response.text)
|
||||||
|
commits = commits_response.json()["commits"]
|
||||||
|
self.assertGreaterEqual(len(commits), 2)
|
||||||
|
|
||||||
|
commit_rows = {row["status"]: row for row in commits}
|
||||||
|
self.assertIn("PUBLISH_COMMIT_READY", commit_rows)
|
||||||
|
self.assertIn("PUBLISH_COMMIT_CREATED", commit_rows)
|
||||||
|
|
||||||
|
created = commit_rows["PUBLISH_COMMIT_CREATED"]
|
||||||
|
self.assertEqual(str(self.bare_repo_path), created["repository_url"])
|
||||||
|
self.assertEqual("main", created["branch"])
|
||||||
|
self.assertTrue(created["commit_sha"])
|
||||||
|
manifest = created["content_bundle_manifest"]
|
||||||
|
self.assertIn("content", manifest)
|
||||||
|
self.assertIn("frontmatter", manifest)
|
||||||
|
self.assertIn("assets", manifest)
|
||||||
|
self.assertIn("config_version", manifest)
|
||||||
|
self.assertIn("git", manifest)
|
||||||
|
self.assertEqual("main", manifest["git"]["branch"])
|
||||||
|
self.assertEqual(str(self.bare_repo_path), manifest["git"]["repository_url"])
|
||||||
|
self.assertIn("best-effort", manifest["validation"]["label"].lower())
|
||||||
|
self.assertEqual("content/articles/git-publishing-flow.mdx", manifest["content"]["path"])
|
||||||
|
|
||||||
|
def _prepare_local_bare_git_repo(self) -> None:
|
||||||
|
self._run_git(["init", "--bare", str(self.bare_repo_path)])
|
||||||
|
self.seed_repo_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._run_git(["init"], cwd=self.seed_repo_path)
|
||||||
|
self._run_git(["config", "user.name", "Pipeline Bot"], cwd=self.seed_repo_path)
|
||||||
|
self._run_git(["config", "user.email", "pipeline-bot@example.com"], cwd=self.seed_repo_path)
|
||||||
|
(self.seed_repo_path / "README.md").write_text("# Demo Site\n", encoding="utf-8")
|
||||||
|
self._run_git(["add", "README.md"], cwd=self.seed_repo_path)
|
||||||
|
self._run_git(["commit", "-m", "seed"], cwd=self.seed_repo_path)
|
||||||
|
self._run_git(["branch", "-M", "main"], cwd=self.seed_repo_path)
|
||||||
|
self._run_git(["remote", "add", "origin", str(self.bare_repo_path)], cwd=self.seed_repo_path)
|
||||||
|
self._run_git(["push", "origin", "main"], cwd=self.seed_repo_path)
|
||||||
|
|
||||||
|
def _rewrite_asset_urls_to_s3(self, article_id: str) -> None:
|
||||||
|
assets = self.repository.assets.list_for_article(UUID(article_id))
|
||||||
|
now = self.repository.articles.get(UUID(article_id)).updated_at
|
||||||
|
for asset in assets:
|
||||||
|
if asset.status.value != "APPROVED":
|
||||||
|
continue
|
||||||
|
self.assertTrue(asset.object_key)
|
||||||
|
self.repository.assets.update(
|
||||||
|
article_id=UUID(article_id),
|
||||||
|
asset_id=asset.id,
|
||||||
|
section_id=asset.section_id,
|
||||||
|
title=asset.title,
|
||||||
|
prompt=asset.prompt,
|
||||||
|
object_key=asset.object_key,
|
||||||
|
file_url=f"s3://pipeline-local/{asset.object_key}",
|
||||||
|
alt_text=asset.alt_text,
|
||||||
|
caption=asset.caption,
|
||||||
|
status=asset.status,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _run_git(self, args: list[str], *, cwd: Path | None = None) -> None:
|
||||||
|
subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=str(cwd) if cwd is not None else None,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _push_remote_commit(self, message: str) -> None:
|
||||||
|
readme_path = self.seed_repo_path / "README.md"
|
||||||
|
readme_path.write_text(
|
||||||
|
readme_path.read_text(encoding="utf-8") + f"\n{message}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
self._run_git(["add", "README.md"], cwd=self.seed_repo_path)
|
||||||
|
self._run_git(["commit", "-m", message], cwd=self.seed_repo_path)
|
||||||
|
self._run_git(["push", "origin", "main"], cwd=self.seed_repo_path)
|
||||||
|
|
||||||
|
def _prepare_final_approved_article(self) -> tuple[str, dict[str, Any]]:
|
||||||
|
article_id, draft = self._prepare_article_ready_for_final_approval()
|
||||||
|
self._submit_final_approval(article_id, draft=draft)
|
||||||
|
return article_id, draft
|
||||||
|
|
||||||
|
def _prepare_article_ready_for_final_approval(self) -> tuple[str, dict[str, Any]]:
|
||||||
|
site_id = self._configure_local_git_repository_for_site()
|
||||||
|
article_id = self._create_article_with_approved_plan(site_id)
|
||||||
|
self._ensure_evidence_ready(article_id)
|
||||||
|
self._approve_all_evidence(article_id)
|
||||||
|
|
||||||
|
section_jobs = self._start_parallel_production(article_id)
|
||||||
|
self.assertGreaterEqual(len(section_jobs), 1)
|
||||||
|
for index, job in enumerate(section_jobs, start=1):
|
||||||
|
completed = self._complete_job(
|
||||||
|
job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": f"outputs/section-{index}.md"}],
|
||||||
|
"payload": {
|
||||||
|
"used_evidence_ids": job["payload"]["used_evidence_ids"],
|
||||||
|
"unsupported_claims": [],
|
||||||
|
"draft_markdown": (
|
||||||
|
f"## {job['payload']['heading']}\n\n"
|
||||||
|
"Publishing-ready section with [internal link](/guides/internal)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("SUCCEEDED", completed["status"])
|
||||||
|
|
||||||
|
assemble_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/assemble",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, assemble_response.status_code, assemble_response.text)
|
||||||
|
draft = assemble_response.json()["draft"]
|
||||||
|
|
||||||
|
assets_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/generate-specs",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, assets_response.status_code, assets_response.text)
|
||||||
|
assets = assets_response.json()["assets"]
|
||||||
|
self.assertTrue(assets)
|
||||||
|
for asset in assets:
|
||||||
|
upload_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/{asset['id']}/upload",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"filename": f"{asset['id']}.png",
|
||||||
|
"content_base64": base64.b64encode(b"asset-binary").decode("utf-8"),
|
||||||
|
"content_type": "image/png",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, upload_response.status_code, upload_response.text)
|
||||||
|
approve_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/{asset['id']}/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, approve_response.status_code, approve_response.text)
|
||||||
|
|
||||||
|
return article_id, draft
|
||||||
|
|
||||||
|
def _submit_final_approval(self, article_id: str, *, draft: dict[str, Any]) -> None:
|
||||||
|
approval_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/final-approval",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"draft_version": draft["version"],
|
||||||
|
"publishing_settings": {
|
||||||
|
"content_path": "/guides/git-publishing-flow",
|
||||||
|
"author": "Editorial Team",
|
||||||
|
"publishing_mode": "MANUAL",
|
||||||
|
"frontmatter": {"title": draft["title"], "category": "Guides"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, approval_response.status_code, approval_response.text)
|
||||||
|
self.assertEqual("PUBLISH_DRY_RUN_REQUIRED", approval_response.json()["article"]["status"])
|
||||||
|
|
||||||
|
def _configure_local_git_repository_for_site(self) -> UUID:
|
||||||
|
sites_response = self.client.get(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, sites_response.status_code, sites_response.text)
|
||||||
|
site = sites_response.json()[0]["site"]
|
||||||
|
|
||||||
|
publishing_rules = dict(site["publishing_rules"])
|
||||||
|
publishing_rules["repository_url"] = str(self.bare_repo_path)
|
||||||
|
publishing_rules["production_branch"] = "main"
|
||||||
|
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/sites/{site['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json={"publishing_rules": publishing_rules},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
return UUID(site["id"])
|
||||||
|
|
||||||
|
def _create_article_with_approved_plan(self, site_id: UUID) -> str:
|
||||||
|
article_response = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"target_site_id": str(site_id),
|
||||||
|
"brief_description": "Publish final-approved content into local git repository.",
|
||||||
|
"working_title": "Git Publishing Dry Run And Commit",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "git publishing dry run",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, article_response.status_code, article_response.text)
|
||||||
|
article_id = article_response.json()["article"]["id"]
|
||||||
|
|
||||||
|
questions_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, questions_response.status_code, questions_response.text)
|
||||||
|
questions = questions_response.json()["questions"]
|
||||||
|
for question in questions:
|
||||||
|
if question["is_required"]:
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"answer": f"Answer for {question['category']}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
|
||||||
|
submit_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, submit_response.status_code, submit_response.text)
|
||||||
|
|
||||||
|
plan_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plan/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, plan_response.status_code, plan_response.text)
|
||||||
|
plan = plan_response.json()["plan"]
|
||||||
|
self.assertGreaterEqual(len(plan["sections"]), 1)
|
||||||
|
|
||||||
|
approve_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, approve_response.status_code, approve_response.text)
|
||||||
|
return article_id
|
||||||
|
|
||||||
|
def _ensure_evidence_ready(self, article_id: str) -> None:
|
||||||
|
research_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/research/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, research_response.status_code, research_response.text)
|
||||||
|
|
||||||
|
evidence_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/evidence",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
|
||||||
|
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
|
||||||
|
|
||||||
|
def _approve_all_evidence(self, article_id: str) -> None:
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/evidence",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
for item in response.json()["evidence"]:
|
||||||
|
patch = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/evidence/{item['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"review_status": "APPROVED"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch.status_code, patch.text)
|
||||||
|
|
||||||
|
def _start_parallel_production(self, article_id: str) -> list[dict[str, Any]]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(202, response.status_code, response.text)
|
||||||
|
return [job for job in response.json()["jobs"] if job["job_type"] == "SECTION_SCAFFOLD"]
|
||||||
|
|
||||||
|
def _complete_job(self, job_id: str, *, output: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/internal/agent-jobs/{job_id}/complete",
|
||||||
|
json={
|
||||||
|
"workspace_path": f"/tmp/{job_id}",
|
||||||
|
"stdout": "fake section scaffolding runner\n",
|
||||||
|
"stderr": "",
|
||||||
|
"exit_code": 0,
|
||||||
|
"duration_ms": 1,
|
||||||
|
"output": output,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
return response.json()["job"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||||
|
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||||
|
from src.presentation.dependencies import get_repository # noqa: E402
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchManifestPublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.storage_root = Path(self.tmp_dir.name) / "object-storage"
|
||||||
|
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(self.storage_root)
|
||||||
|
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'research.db'}"
|
||||||
|
self.repository = open_backend_repository(dsn)
|
||||||
|
self.repository.setup()
|
||||||
|
seed_reference_data(self.repository)
|
||||||
|
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
|
||||||
|
self.tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_approved_plan_research_creates_manifest_with_uploaded_artifacts(self) -> None:
|
||||||
|
article_id = self._create_article_with_approved_plan()
|
||||||
|
|
||||||
|
start_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/research/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, start_response.status_code, start_response.text)
|
||||||
|
body = start_response.json()
|
||||||
|
self.assertEqual("RESEARCH_RUNNING", body["article"]["status"])
|
||||||
|
self.assertEqual("RESEARCH", body["job"]["job_type"])
|
||||||
|
|
||||||
|
manifest = body["manifest"]
|
||||||
|
self.assertTrue(manifest["s3_prefix"])
|
||||||
|
self.assertEqual(article_id, manifest["article_id"])
|
||||||
|
self.assertEqual(body["job"]["id"], manifest["agent_job_id"])
|
||||||
|
self.assertGreaterEqual(len(manifest["artifacts"]), 2)
|
||||||
|
|
||||||
|
object_keys = [artifact["object_key"] for artifact in manifest["artifacts"]]
|
||||||
|
content_hashes = [artifact["content_hash"] for artifact in manifest["artifacts"]]
|
||||||
|
artifact_types = [artifact["artifact_type"] for artifact in manifest["artifacts"]]
|
||||||
|
source_urls = [artifact["source_url"] for artifact in manifest["artifacts"]]
|
||||||
|
|
||||||
|
self.assertEqual(len(object_keys), len(set(object_keys)))
|
||||||
|
self.assertTrue(all(key.startswith(manifest["s3_prefix"]) for key in object_keys))
|
||||||
|
self.assertTrue(all(content_hashes))
|
||||||
|
self.assertIn("source_section_snapshot", artifact_types)
|
||||||
|
self.assertTrue(all(url.startswith("https://") for url in source_urls))
|
||||||
|
|
||||||
|
for artifact in manifest["artifacts"]:
|
||||||
|
metadata = artifact["metadata"]
|
||||||
|
self.assertTrue(metadata["source_title"])
|
||||||
|
self.assertTrue(metadata["domain"])
|
||||||
|
self.assertTrue(metadata["summary"])
|
||||||
|
self.assertTrue(metadata["retrieved_at"])
|
||||||
|
self.assertTrue(metadata["artifact_link"])
|
||||||
|
self.assertIn("search_path", metadata)
|
||||||
|
self.assertIn("selection_criteria", metadata)
|
||||||
|
self.assertIn("head_metadata", metadata)
|
||||||
|
self.assertIn("server_ip_address", metadata)
|
||||||
|
self.assertIn("whois_owner", metadata)
|
||||||
|
self.assertTrue((self.storage_root / artifact["object_key"]).is_file())
|
||||||
|
|
||||||
|
list_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/research",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, list_response.status_code, list_response.text)
|
||||||
|
self.assertEqual([manifest["id"]], [item["id"] for item in list_response.json()["manifests"]])
|
||||||
|
|
||||||
|
rerun_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/research/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, rerun_response.status_code, rerun_response.text)
|
||||||
|
self.assertNotEqual(manifest["id"], rerun_response.json()["manifest"]["id"])
|
||||||
|
self.assertNotEqual(
|
||||||
|
manifest["s3_prefix"],
|
||||||
|
rerun_response.json()["manifest"]["s3_prefix"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_article_with_approved_plan(self) -> str:
|
||||||
|
site = self.client.get(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()[0]["site"]
|
||||||
|
article = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"target_site_id": site["id"],
|
||||||
|
"brief_description": "Research auditable AI content operations.",
|
||||||
|
"working_title": "Auditable AI Content Operations",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "auditable AI content",
|
||||||
|
},
|
||||||
|
).json()["article"]
|
||||||
|
article_id = article["id"]
|
||||||
|
|
||||||
|
questions = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()["questions"]
|
||||||
|
for question in questions:
|
||||||
|
if question["is_required"]:
|
||||||
|
response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"answer": f"Answer for {question['category']}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
plan = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plan/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()["plan"]
|
||||||
|
approve_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, approve_response.status_code, approve_response.text)
|
||||||
|
return article_id
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def repository_dsn() -> Iterator[str]:
|
||||||
|
configured_dsn = os.environ.get("PIPELINE_TEST_DATABASE_DSN")
|
||||||
|
if configured_dsn:
|
||||||
|
yield configured_dsn
|
||||||
|
return
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
yield f"sqlite:///{Path(tmp_dir) / 'pipeline-schema.db'}"
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaStorageContractsIntegrationTest(unittest.TestCase):
|
||||||
|
def test_schema_contains_task_tables_and_operational_indexes(self) -> None:
|
||||||
|
from src.domain.schema import CORE_TABLES
|
||||||
|
from src.infrastructure.repositories import open_backend_repository
|
||||||
|
|
||||||
|
with repository_dsn() as dsn:
|
||||||
|
repository = open_backend_repository(dsn)
|
||||||
|
repository.setup()
|
||||||
|
|
||||||
|
self.assertTrue(set(CORE_TABLES).issubset(repository.schema.list_tables()))
|
||||||
|
self.assertIn(
|
||||||
|
"idx_target_sites_slug",
|
||||||
|
repository.schema.list_indexes("target_sites"),
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"idx_articles_dashboard",
|
||||||
|
repository.schema.list_indexes("articles"),
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"idx_agent_jobs_queue",
|
||||||
|
repository.schema.list_indexes("agent_jobs"),
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"idx_workflow_events_timeline",
|
||||||
|
repository.schema.list_indexes("workflow_events"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_required_domain_storage_fields_exist(self) -> None:
|
||||||
|
from src.infrastructure.repositories import open_backend_repository
|
||||||
|
|
||||||
|
with repository_dsn() as dsn:
|
||||||
|
repository = open_backend_repository(dsn)
|
||||||
|
repository.setup()
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
{"status", "publishing_status"}.issubset(
|
||||||
|
repository.schema.list_columns("articles")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIn("status", repository.schema.list_columns("article_drafts"))
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"created_by",
|
||||||
|
"created_at",
|
||||||
|
"diff",
|
||||||
|
"rollback_target_version_id",
|
||||||
|
"activated_at",
|
||||||
|
"publishing_yaml",
|
||||||
|
"transform_script",
|
||||||
|
}.issubset(repository.schema.list_columns("script_config_versions"))
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"parent_job_id",
|
||||||
|
"attempt",
|
||||||
|
"stdout",
|
||||||
|
"stderr",
|
||||||
|
"exit_code",
|
||||||
|
"duration_ms",
|
||||||
|
"workspace_path",
|
||||||
|
"output_files",
|
||||||
|
}.issubset(repository.schema.list_columns("agent_jobs"))
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"category",
|
||||||
|
repository.schema.list_columns("boundary_questions"),
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"title_options",
|
||||||
|
"reader_persona",
|
||||||
|
"evidence_needs",
|
||||||
|
"visual_needs",
|
||||||
|
"seo_notes",
|
||||||
|
"source_requirements",
|
||||||
|
"excluded_sources",
|
||||||
|
"tone",
|
||||||
|
"audience",
|
||||||
|
}.issubset(repository.schema.list_columns("article_plans"))
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
{"key_points", "evidence_needs"}.issubset(
|
||||||
|
repository.schema.list_columns("plan_sections")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"s3_prefix",
|
||||||
|
"source_urls",
|
||||||
|
"object_keys",
|
||||||
|
"content_hashes",
|
||||||
|
"artifact_types",
|
||||||
|
"metadata_references",
|
||||||
|
"artifacts",
|
||||||
|
}.issubset(repository.schema.list_columns("research_run_manifests"))
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"review_status",
|
||||||
|
repository.schema.list_columns("evidence_items"),
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"repository_url",
|
||||||
|
"branch",
|
||||||
|
"commit_sha",
|
||||||
|
"content_bundle_manifest",
|
||||||
|
"status",
|
||||||
|
}.issubset(repository.schema.list_columns("publish_commits"))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_seed_data_is_idempotent_for_users_site_and_script_version(self) -> None:
|
||||||
|
from src.application.seed_data import seed_reference_data
|
||||||
|
from src.domain.schema import (
|
||||||
|
SEEDED_ADMIN_EMAIL,
|
||||||
|
SEEDED_EDITOR_EMAIL,
|
||||||
|
SEEDED_TARGET_SITE_SLUG,
|
||||||
|
)
|
||||||
|
from src.infrastructure.repositories import open_backend_repository
|
||||||
|
|
||||||
|
with repository_dsn() as dsn:
|
||||||
|
repository = open_backend_repository(dsn)
|
||||||
|
repository.setup()
|
||||||
|
|
||||||
|
seed_reference_data(repository)
|
||||||
|
seed_reference_data(repository)
|
||||||
|
|
||||||
|
site = repository.target_sites.get_by_slug(SEEDED_TARGET_SITE_SLUG)
|
||||||
|
seeded_sites = [
|
||||||
|
candidate
|
||||||
|
for candidate in repository.target_sites.list()
|
||||||
|
if candidate.slug == SEEDED_TARGET_SITE_SLUG
|
||||||
|
]
|
||||||
|
script_versions = repository.script_config_versions.list_for_site(site.id)
|
||||||
|
|
||||||
|
self.assertEqual(1, repository.users.count_by_email(SEEDED_ADMIN_EMAIL))
|
||||||
|
self.assertEqual(1, repository.users.count_by_email(SEEDED_EDITOR_EMAIL))
|
||||||
|
self.assertEqual([site.id], [candidate.id for candidate in seeded_sites])
|
||||||
|
self.assertEqual(1, len(script_versions))
|
||||||
|
self.assertEqual("ACTIVE", script_versions[0]["status"])
|
||||||
|
self.assertEqual(
|
||||||
|
str(site.active_script_config_version_id), str(script_versions[0]["id"])
|
||||||
|
)
|
||||||
|
self.assertIn("repository:", script_versions[0]["publishing_yaml"])
|
||||||
|
self.assertIn("transformArticle", script_versions[0]["transform_script"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import sys
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def repository_dsn() -> Iterator[str]:
|
||||||
|
configured = os.environ.get("PIPELINE_TEST_DATABASE_DSN")
|
||||||
|
if configured:
|
||||||
|
yield configured
|
||||||
|
return
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
yield f"sqlite:///{Path(directory) / 'pipeline-audit.db'}"
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptConfigAuditTest(unittest.TestCase):
|
||||||
|
def test_rollback_writes_audit_event(self) -> None:
|
||||||
|
with repository_dsn() as dsn:
|
||||||
|
from src.application.auth import get_current_user_by_email
|
||||||
|
from src.application.seed_data import seed_reference_data
|
||||||
|
from src.application.site_config import (
|
||||||
|
create_script_config_version,
|
||||||
|
create_target_site,
|
||||||
|
rollback_script_config_version,
|
||||||
|
)
|
||||||
|
from src.domain.contracts import (
|
||||||
|
PublishingRules,
|
||||||
|
ScriptConfigVersionCreateRequest,
|
||||||
|
TargetSiteConfigCreateRequest,
|
||||||
|
)
|
||||||
|
from src.infrastructure.repositories import open_backend_repository
|
||||||
|
|
||||||
|
repository = open_backend_repository(dsn)
|
||||||
|
repository.setup()
|
||||||
|
seed_reference_data(repository)
|
||||||
|
admin = get_current_user_by_email(repository, "admin@example.com")
|
||||||
|
site = create_target_site(
|
||||||
|
repository,
|
||||||
|
TargetSiteConfigCreateRequest(
|
||||||
|
name="Audit Test Site",
|
||||||
|
slug="audit-site",
|
||||||
|
publishing_type="git_next",
|
||||||
|
default_language="en",
|
||||||
|
brand_voice="Audit-focused publishing profile.",
|
||||||
|
audience="QA operations team.",
|
||||||
|
seo_rules={"primary_keyword_required": True},
|
||||||
|
visual_rules={"hero_style": "technical"},
|
||||||
|
source_rules={"minimum_sources": 1},
|
||||||
|
publishing_rules=PublishingRules(
|
||||||
|
repository_url="git@github.com:example/audit-site.git",
|
||||||
|
production_branch="main",
|
||||||
|
content_format="mdx",
|
||||||
|
content_path_template="content/articles/{slug}.mdx",
|
||||||
|
asset_path_template="public/articles/{slug}/{filename}",
|
||||||
|
frontmatter_mapping={"title": "title"},
|
||||||
|
dry_run_renderer="next-mdx",
|
||||||
|
),
|
||||||
|
active_script_config_version_id=None,
|
||||||
|
),
|
||||||
|
).site
|
||||||
|
|
||||||
|
initial = create_script_config_version(
|
||||||
|
repository,
|
||||||
|
site_id=site.id,
|
||||||
|
current_user=admin,
|
||||||
|
request=self._script_version_payload(summary="initial", activate=True),
|
||||||
|
).version
|
||||||
|
candidate = create_script_config_version(
|
||||||
|
repository,
|
||||||
|
site_id=site.id,
|
||||||
|
current_user=admin,
|
||||||
|
request=self._script_version_payload(
|
||||||
|
summary="candidate",
|
||||||
|
activate=True,
|
||||||
|
rollback_target_version_id=initial.id,
|
||||||
|
),
|
||||||
|
).version
|
||||||
|
|
||||||
|
rolled_back = rollback_script_config_version(
|
||||||
|
repository,
|
||||||
|
site_id=site.id,
|
||||||
|
version_id=initial.id,
|
||||||
|
current_user=admin,
|
||||||
|
)
|
||||||
|
self.assertEqual(str(initial.id), str(rolled_back.version.id))
|
||||||
|
|
||||||
|
rollback_events = [
|
||||||
|
event
|
||||||
|
for event in repository.script_config_version_events.list_for_site(site.id)
|
||||||
|
if event["event_type"] == "SCRIPT_CONFIG_VERSION_ROLLBACK"
|
||||||
|
]
|
||||||
|
self.assertEqual(1, len(rollback_events))
|
||||||
|
rollback_event = rollback_events[0]
|
||||||
|
self.assertEqual(str(site.id), str(rollback_event["target_site_id"]))
|
||||||
|
self.assertEqual(str(initial.id), str(rollback_event["version_id"]))
|
||||||
|
self.assertEqual(str(admin.id), str(rollback_event["actor_user_id"]))
|
||||||
|
self.assertEqual(
|
||||||
|
str(candidate.id),
|
||||||
|
rollback_event["payload"]["rollback_target_version_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
active_site = repository.target_sites.get_by_id(site.id)
|
||||||
|
self.assertEqual(initial.id, active_site.active_script_config_version_id)
|
||||||
|
|
||||||
|
def _script_version_payload(
|
||||||
|
self,
|
||||||
|
summary: str,
|
||||||
|
activate: bool = False,
|
||||||
|
rollback_target_version_id: UUID | None = None,
|
||||||
|
) -> ScriptConfigVersionCreateRequest:
|
||||||
|
from src.domain.contracts import ScriptConfigVersionCreateRequest
|
||||||
|
|
||||||
|
payload = ScriptConfigVersionCreateRequest(
|
||||||
|
publishing_yaml="target:\n repo: audit\n",
|
||||||
|
transform_script="export function transformArticle(article) { return article; }",
|
||||||
|
diff={"summary": summary},
|
||||||
|
activate=activate,
|
||||||
|
)
|
||||||
|
if rollback_target_version_id is not None:
|
||||||
|
payload.rollback_target_version_id = rollback_target_version_id
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
SEEDED_SITE_SLUG = "b2b_saas_blog"
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def repository_dsn() -> Iterator[str]:
|
||||||
|
configured_dsn = os.environ.get("PIPELINE_TEST_DATABASE_DSN")
|
||||||
|
if configured_dsn:
|
||||||
|
yield configured_dsn
|
||||||
|
return
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
yield f"sqlite:///{Path(tmp_dir) / 'pipeline-seed-red.db'}"
|
||||||
|
|
||||||
|
|
||||||
|
class SeededTargetSiteLookupIntegrationTest(unittest.TestCase):
|
||||||
|
def test_seeded_git_next_target_site_lookup_by_slug_is_idempotent(self) -> None:
|
||||||
|
from src.application.seed_data import seed_reference_data
|
||||||
|
from src.infrastructure.repositories import open_backend_repository
|
||||||
|
|
||||||
|
with repository_dsn() as dsn:
|
||||||
|
repository = open_backend_repository(dsn)
|
||||||
|
repository.setup()
|
||||||
|
|
||||||
|
seed_reference_data(repository)
|
||||||
|
seed_reference_data(repository)
|
||||||
|
|
||||||
|
site = repository.target_sites.get_by_slug(SEEDED_SITE_SLUG)
|
||||||
|
seeded_sites = [
|
||||||
|
candidate
|
||||||
|
for candidate in repository.target_sites.list()
|
||||||
|
if candidate.slug == SEEDED_SITE_SLUG
|
||||||
|
]
|
||||||
|
|
||||||
|
from src.domain.contracts import TargetSiteConfig
|
||||||
|
|
||||||
|
self.assertIsInstance(site, TargetSiteConfig)
|
||||||
|
self.assertEqual([site.id], [candidate.id for candidate in seeded_sites])
|
||||||
|
self.assertEqual("B2B SaaS Blog", site.name)
|
||||||
|
self.assertEqual("git_next", site.publishing_type)
|
||||||
|
self.assertEqual("en", site.default_language)
|
||||||
|
self.assertEqual(
|
||||||
|
"git@github.com:example/site.git",
|
||||||
|
site.publishing_rules.repository_url,
|
||||||
|
)
|
||||||
|
self.assertEqual("main", site.publishing_rules.production_branch)
|
||||||
|
self.assertEqual("mdx", site.publishing_rules.content_format)
|
||||||
|
self.assertEqual(
|
||||||
|
"content/articles/{slug}.mdx",
|
||||||
|
site.publishing_rules.content_path_template,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"public/articles/{slug}/{filename}",
|
||||||
|
site.publishing_rules.asset_path_template,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(site.active_script_config_version_id)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||||
|
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||||
|
from src.presentation.dependencies import get_repository # noqa: E402
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||||
|
DEMO_ADMIN_EMAIL = "admin@example.com"
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class SeoLanguageReviewPublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects")
|
||||||
|
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'seo-language-review.db'}"
|
||||||
|
self.repository = open_backend_repository(dsn)
|
||||||
|
self.repository.setup()
|
||||||
|
seed_reference_data(self.repository)
|
||||||
|
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
|
||||||
|
self.tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_seo_report_includes_required_fields(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_assembled_draft()
|
||||||
|
self._run_seo_review(article_id)
|
||||||
|
|
||||||
|
report = self._get_seo_report(article_id)
|
||||||
|
self.assertIn("score", report)
|
||||||
|
self.assertIn("issues", report)
|
||||||
|
self.assertIn("recommended_slug", report)
|
||||||
|
self.assertIn("recommended_title", report)
|
||||||
|
self.assertIn("schema_json", report)
|
||||||
|
self.assertGreaterEqual(len(report["issues"]), 1)
|
||||||
|
|
||||||
|
first_issue = report["issues"][0]
|
||||||
|
self.assertIn("severity", first_issue)
|
||||||
|
self.assertIn("location", first_issue)
|
||||||
|
self.assertIn("message", first_issue)
|
||||||
|
self.assertIn("suggested_fix", first_issue)
|
||||||
|
self.assertIn("suggestion_id", first_issue)
|
||||||
|
|
||||||
|
def test_language_report_includes_required_fields(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_assembled_draft()
|
||||||
|
self._seed_language_problematic_draft(article_id)
|
||||||
|
self._run_language_review(article_id)
|
||||||
|
|
||||||
|
report = self._get_language_report(article_id)
|
||||||
|
self.assertGreaterEqual(len(report["issues"]), 1)
|
||||||
|
self.assertTrue(any(issue.get("suggested_rewrite") for issue in report["issues"]))
|
||||||
|
first_issue = report["issues"][0]
|
||||||
|
self.assertIn("severity", first_issue)
|
||||||
|
self.assertIn("location", first_issue)
|
||||||
|
self.assertIn("message", first_issue)
|
||||||
|
self.assertIn("suggested_rewrite", first_issue)
|
||||||
|
|
||||||
|
def test_editor_actions_accept_reject_edit_and_immutable_drafts(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_assembled_draft()
|
||||||
|
self._seed_language_problematic_draft(article_id)
|
||||||
|
|
||||||
|
self._run_seo_review(article_id)
|
||||||
|
self._run_language_review(article_id)
|
||||||
|
|
||||||
|
seo_report = self._get_seo_report(article_id)
|
||||||
|
language_report = self._get_language_report(article_id)
|
||||||
|
base_latest = self._latest_draft(article_id)
|
||||||
|
|
||||||
|
seo_patch_issue = next(
|
||||||
|
issue for issue in seo_report["issues"] if issue["location"] == "meta_description"
|
||||||
|
)
|
||||||
|
accept_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/seo/suggestions/{seo_patch_issue['suggestion_id']}/accept",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, accept_response.status_code, accept_response.text)
|
||||||
|
accepted = accept_response.json()
|
||||||
|
self.assertEqual("ACCEPTED", accepted["suggestion"]["status"])
|
||||||
|
self.assertEqual(base_latest["version"] + 1, accepted["draft"]["version"])
|
||||||
|
|
||||||
|
seo_non_patch_issue = next(
|
||||||
|
issue for issue in seo_report["issues"] if issue["location"] == "body_markdown"
|
||||||
|
)
|
||||||
|
reject_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/seo/suggestions/{seo_non_patch_issue['suggestion_id']}/reject",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, reject_response.status_code, reject_response.text)
|
||||||
|
self.assertEqual("REJECTED", reject_response.json()["suggestion"]["status"])
|
||||||
|
self.assertIsNone(reject_response.json()["draft"])
|
||||||
|
|
||||||
|
language_rewrite_issue = next(
|
||||||
|
issue for issue in language_report["issues"] if "Sentence exceeds" in issue["message"]
|
||||||
|
)
|
||||||
|
edit_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/language/suggestions/{language_rewrite_issue['suggestion_id']}/edit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"applied_text": "This sentence now states the same idea clearly."},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, edit_response.status_code, edit_response.text)
|
||||||
|
edited = edit_response.json()
|
||||||
|
self.assertEqual("EDITED", edited["suggestion"]["status"])
|
||||||
|
self.assertEqual(
|
||||||
|
accepted["draft"]["version"] + 1,
|
||||||
|
edited["draft"]["version"],
|
||||||
|
)
|
||||||
|
self.assertIn("states the same idea clearly", edited["draft"]["body_markdown"])
|
||||||
|
|
||||||
|
def test_target_site_overrides_are_applied(self) -> None:
|
||||||
|
site = self.client.get(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()[0]["site"]
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/sites/{site['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json={
|
||||||
|
"seo_rules": {
|
||||||
|
"seo_review": {
|
||||||
|
"meta_description_min": 10,
|
||||||
|
"meta_description_max": 40,
|
||||||
|
"schema_type": "HowTo",
|
||||||
|
},
|
||||||
|
"language_review": {
|
||||||
|
"max_sentence_words": 8,
|
||||||
|
"forbidden_phrases": ["precision tone"],
|
||||||
|
"brand_tone": "strict",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
|
||||||
|
article_id = self._prepare_article_with_assembled_draft(target_site_id=UUID(site["id"]))
|
||||||
|
self._seed_language_problematic_draft(
|
||||||
|
article_id,
|
||||||
|
extra_body="This precision tone phrase should be avoided!",
|
||||||
|
)
|
||||||
|
self._run_seo_review(article_id)
|
||||||
|
self._run_language_review(article_id)
|
||||||
|
|
||||||
|
seo_report = self._get_seo_report(article_id)
|
||||||
|
self.assertEqual("HowTo", seo_report["schema_json"]["@type"])
|
||||||
|
|
||||||
|
language_report = self._get_language_report(article_id)
|
||||||
|
self.assertEqual(8, language_report["rules_snapshot"]["max_sentence_words"])
|
||||||
|
self.assertEqual("strict", language_report["rules_snapshot"]["brand_tone"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("8 words" in issue["message"] for issue in language_report["issues"])
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any("Forbidden phrase" in issue["message"] for issue in language_report["issues"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_final_review_shows_unresolved_issues(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_assembled_draft()
|
||||||
|
self._seed_language_problematic_draft(article_id)
|
||||||
|
self._run_seo_review(article_id)
|
||||||
|
self._run_language_review(article_id)
|
||||||
|
|
||||||
|
seo_report = self._get_seo_report(article_id)
|
||||||
|
issue_to_accept = seo_report["issues"][0]
|
||||||
|
accept_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/seo/suggestions/{issue_to_accept['suggestion_id']}/accept",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, accept_response.status_code, accept_response.text)
|
||||||
|
|
||||||
|
unresolved_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/final-review/issues",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, unresolved_response.status_code, unresolved_response.text)
|
||||||
|
unresolved = unresolved_response.json()
|
||||||
|
self.assertGreaterEqual(unresolved["unresolved_count"], 1)
|
||||||
|
self.assertTrue(unresolved["unresolved_issues"])
|
||||||
|
self.assertTrue(
|
||||||
|
all(item["status"] == "PENDING" for item in unresolved["unresolved_issues"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_fake_runner_is_deterministic(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_assembled_draft()
|
||||||
|
self._seed_language_problematic_draft(article_id)
|
||||||
|
|
||||||
|
self._run_seo_review(article_id)
|
||||||
|
first = self._get_seo_report(article_id)
|
||||||
|
self._run_seo_review(article_id)
|
||||||
|
second = self._get_seo_report(article_id)
|
||||||
|
|
||||||
|
self.assertEqual(first["score"], second["score"])
|
||||||
|
self.assertEqual(first["recommended_slug"], second["recommended_slug"])
|
||||||
|
self.assertEqual(first["recommended_title"], second["recommended_title"])
|
||||||
|
|
||||||
|
first_issue_signature = [
|
||||||
|
(
|
||||||
|
issue["id"],
|
||||||
|
issue["severity"],
|
||||||
|
issue["location"],
|
||||||
|
issue["message"],
|
||||||
|
issue["suggested_fix"],
|
||||||
|
issue["suggested_rewrite"],
|
||||||
|
)
|
||||||
|
for issue in first["issues"]
|
||||||
|
]
|
||||||
|
second_issue_signature = [
|
||||||
|
(
|
||||||
|
issue["id"],
|
||||||
|
issue["severity"],
|
||||||
|
issue["location"],
|
||||||
|
issue["message"],
|
||||||
|
issue["suggested_fix"],
|
||||||
|
issue["suggested_rewrite"],
|
||||||
|
)
|
||||||
|
for issue in second["issues"]
|
||||||
|
]
|
||||||
|
self.assertEqual(first_issue_signature, second_issue_signature)
|
||||||
|
|
||||||
|
def _run_seo_review(self, article_id: UUID) -> dict[str, Any]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/seo/review",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, response.status_code, response.text)
|
||||||
|
return response.json()["report"]
|
||||||
|
|
||||||
|
def _run_language_review(self, article_id: UUID) -> dict[str, Any]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/language/review",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, response.status_code, response.text)
|
||||||
|
return response.json()["report"]
|
||||||
|
|
||||||
|
def _get_seo_report(self, article_id: UUID) -> dict[str, Any]:
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/seo/report",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
return response.json()["report"]
|
||||||
|
|
||||||
|
def _get_language_report(self, article_id: UUID) -> dict[str, Any]:
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/language/report",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
return response.json()["report"]
|
||||||
|
|
||||||
|
def _latest_draft(self, article_id: UUID) -> dict[str, Any]:
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/drafts",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
return response.json()["drafts"][0]
|
||||||
|
|
||||||
|
def _seed_language_problematic_draft(
|
||||||
|
self,
|
||||||
|
article_id: UUID,
|
||||||
|
*,
|
||||||
|
extra_body: str = "",
|
||||||
|
) -> None:
|
||||||
|
latest = self._latest_draft(article_id)
|
||||||
|
body = "\n".join(
|
||||||
|
[
|
||||||
|
latest["body_markdown"],
|
||||||
|
(
|
||||||
|
"This sentence is intentionally extremely long and includes many additional words "
|
||||||
|
"that make it exceed any practical editorial threshold for readability while still "
|
||||||
|
"describing the same guidance in an unnecessarily verbose and repetitive way for testing."
|
||||||
|
),
|
||||||
|
"Our approach is the best ever for everyone.",
|
||||||
|
extra_body,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/drafts/{latest['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"body_markdown": body},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
|
||||||
|
def _prepare_article_with_assembled_draft(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
target_site_id: UUID | None = None,
|
||||||
|
) -> UUID:
|
||||||
|
article_id, _ = self._prepare_article_with_approved_plan(target_site_id=target_site_id)
|
||||||
|
self._ensure_evidence_ready(article_id)
|
||||||
|
section_jobs = self._start_parallel_production(article_id)
|
||||||
|
self._complete_all_section_jobs_success(section_jobs)
|
||||||
|
self._assemble_draft(article_id)
|
||||||
|
return article_id
|
||||||
|
|
||||||
|
def _prepare_article_with_approved_plan(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
target_site_id: UUID | None = None,
|
||||||
|
) -> tuple[UUID, dict[str, Any]]:
|
||||||
|
if target_site_id is None:
|
||||||
|
site = self.client.get(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()[0]["site"]
|
||||||
|
target_site_id = UUID(site["id"])
|
||||||
|
article = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"target_site_id": str(target_site_id),
|
||||||
|
"brief_description": "SEO and language review implementation coverage.",
|
||||||
|
"working_title": "Short",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "seo language review",
|
||||||
|
},
|
||||||
|
).json()["article"]
|
||||||
|
article_id = UUID(article["id"])
|
||||||
|
|
||||||
|
questions = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()["questions"]
|
||||||
|
for question in questions:
|
||||||
|
if question["is_required"]:
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"answer": f"Answer for {question['category']}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
|
||||||
|
submit_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, submit_response.status_code, submit_response.text)
|
||||||
|
|
||||||
|
plan_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plan/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, plan_response.status_code, plan_response.text)
|
||||||
|
plan = plan_response.json()["plan"]
|
||||||
|
|
||||||
|
approve_plan_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, approve_plan_response.status_code, approve_plan_response.text)
|
||||||
|
return article_id, plan
|
||||||
|
|
||||||
|
def _ensure_evidence_ready(self, article_id: UUID) -> None:
|
||||||
|
research_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/research/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, research_response.status_code, research_response.text)
|
||||||
|
|
||||||
|
evidence_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/evidence",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
|
||||||
|
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
|
||||||
|
|
||||||
|
def _start_parallel_production(self, article_id: UUID) -> list[dict[str, Any]]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(202, response.status_code, response.text)
|
||||||
|
jobs = response.json()["jobs"]
|
||||||
|
section_jobs = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"]
|
||||||
|
self.assertTrue(section_jobs)
|
||||||
|
return section_jobs
|
||||||
|
|
||||||
|
def _complete_all_section_jobs_success(self, section_jobs: list[dict[str, Any]]) -> None:
|
||||||
|
for index, job in enumerate(section_jobs, start=1):
|
||||||
|
complete_response = self.client.post(
|
||||||
|
f"/internal/agent-jobs/{job['id']}/complete",
|
||||||
|
json={
|
||||||
|
"workspace_path": f"/tmp/{job['id']}",
|
||||||
|
"stdout": "fake section scaffolding runner\n",
|
||||||
|
"stderr": "",
|
||||||
|
"exit_code": 0,
|
||||||
|
"duration_ms": 1,
|
||||||
|
"output": {
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": f"outputs/section-{index}.md"}],
|
||||||
|
"payload": {
|
||||||
|
"used_evidence_ids": job["payload"]["used_evidence_ids"],
|
||||||
|
"unsupported_claims": [],
|
||||||
|
"draft_markdown": f"## {job['payload']['heading']}\n\nSection {index}.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, complete_response.status_code, complete_response.text)
|
||||||
|
self.assertEqual("SUCCEEDED", complete_response.json()["job"]["status"])
|
||||||
|
|
||||||
|
def _assemble_draft(self, article_id: UUID) -> dict[str, Any]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/assemble",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, response.status_code, response.text)
|
||||||
|
return response.json()["draft"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||||
|
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||||
|
from src.presentation.dependencies import get_repository # noqa: E402
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_ADMIN_EMAIL = "admin@example.com"
|
||||||
|
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTemplateBindingPublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'workflow-template-binding.db'}"
|
||||||
|
self.repository = open_backend_repository(dsn)
|
||||||
|
self.repository.setup()
|
||||||
|
seed_reference_data(self.repository)
|
||||||
|
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
self.tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_article_creation_stores_active_workflow_template_snapshot(self) -> None:
|
||||||
|
workflow = self._create_active_workflow("article-binding-active")
|
||||||
|
target_site = self._first_target_site()
|
||||||
|
|
||||||
|
create_response = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers=self._editor_headers(),
|
||||||
|
json={
|
||||||
|
"target_site_id": target_site["id"],
|
||||||
|
"workflow_template_id": workflow["id"],
|
||||||
|
"brief_description": (
|
||||||
|
"Create an article that is governed by an Admin-managed "
|
||||||
|
f"workflow template {uuid4()}."
|
||||||
|
),
|
||||||
|
"working_title": "Workflow-bound article",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "workflow template binding",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||||
|
article = create_response.json()["article"]
|
||||||
|
self.assertEqual(workflow["id"], article["workflow_template_id"])
|
||||||
|
self.assertEqual(workflow["version"], article["workflow_template_version"])
|
||||||
|
|
||||||
|
archive_response = self.client.post(
|
||||||
|
f"/api/admin/workflows/{workflow['id']}/archive",
|
||||||
|
headers=self._admin_headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual(200, archive_response.status_code, archive_response.text)
|
||||||
|
|
||||||
|
detail_response = self.client.get(
|
||||||
|
f"/api/articles/{article['id']}",
|
||||||
|
headers=self._editor_headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual(200, detail_response.status_code, detail_response.text)
|
||||||
|
detail = detail_response.json()
|
||||||
|
|
||||||
|
expected_stage_summary = [
|
||||||
|
{
|
||||||
|
"id": stage["id"],
|
||||||
|
"stable_key": stage["stable_key"],
|
||||||
|
"display_name": stage["display_name"],
|
||||||
|
"position": stage["position"],
|
||||||
|
"owner_role": stage["owner_role"],
|
||||||
|
"runner_profile_key": stage["runner_profile_key"],
|
||||||
|
"requires_human_approval": stage["requires_human_approval"],
|
||||||
|
}
|
||||||
|
for stage in workflow["stages"]
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"id": workflow["id"],
|
||||||
|
"name": workflow["name"],
|
||||||
|
"slug": workflow["slug"],
|
||||||
|
"version": workflow["version"],
|
||||||
|
"stage_summary": expected_stage_summary,
|
||||||
|
},
|
||||||
|
detail["workflow_template_snapshot"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_article_creation_rejects_archived_workflow_template_reference(self) -> None:
|
||||||
|
workflow = self._create_active_workflow("article-binding-archived")
|
||||||
|
archive_response = self.client.post(
|
||||||
|
f"/api/admin/workflows/{workflow['id']}/archive",
|
||||||
|
headers=self._admin_headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual(200, archive_response.status_code, archive_response.text)
|
||||||
|
|
||||||
|
response = self._create_article_with_workflow_template(workflow["id"])
|
||||||
|
|
||||||
|
self.assertEqual(400, response.status_code, response.text)
|
||||||
|
self.assertEqual("Workflow template must be active", response.json()["detail"])
|
||||||
|
|
||||||
|
def test_article_creation_rejects_missing_workflow_template_reference(self) -> None:
|
||||||
|
response = self._create_article_with_workflow_template(str(uuid4()))
|
||||||
|
|
||||||
|
self.assertEqual(400, response.status_code, response.text)
|
||||||
|
self.assertEqual("Invalid workflow_template_id", response.json()["detail"])
|
||||||
|
|
||||||
|
def test_seeded_active_workflow_template_is_available_for_editor_articles(self) -> None:
|
||||||
|
workflows_response = self.client.get(
|
||||||
|
"/api/admin/workflows",
|
||||||
|
headers=self._editor_headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual(200, workflows_response.status_code, workflows_response.text)
|
||||||
|
seeded_workflow = next(
|
||||||
|
workflow
|
||||||
|
for workflow in workflows_response.json()["workflows"]
|
||||||
|
if workflow["slug"] == "demo-article-production"
|
||||||
|
)
|
||||||
|
self.assertEqual("ACTIVE", seeded_workflow["status"])
|
||||||
|
self.assertGreaterEqual(len(seeded_workflow["stages"]), 7)
|
||||||
|
|
||||||
|
create_response = self._create_article_with_workflow_template(
|
||||||
|
seeded_workflow["id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||||
|
article = create_response.json()["article"]
|
||||||
|
self.assertEqual(seeded_workflow["id"], article["workflow_template_id"])
|
||||||
|
self.assertEqual(
|
||||||
|
seeded_workflow["version"],
|
||||||
|
article["workflow_template_version"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_article_with_workflow_template(self, workflow_template_id: str) -> Any:
|
||||||
|
target_site = self._first_target_site()
|
||||||
|
return self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers=self._editor_headers(),
|
||||||
|
json={
|
||||||
|
"target_site_id": target_site["id"],
|
||||||
|
"workflow_template_id": workflow_template_id,
|
||||||
|
"brief_description": (
|
||||||
|
"Create an article using the selected workflow template "
|
||||||
|
f"{uuid4()}."
|
||||||
|
),
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "workflow binding guardrail",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_active_workflow(self, slug_prefix: str) -> dict[str, Any]:
|
||||||
|
create_response = self.client.post(
|
||||||
|
"/api/admin/workflows",
|
||||||
|
headers=self._admin_headers(),
|
||||||
|
json={
|
||||||
|
"name": "Article production workflow",
|
||||||
|
"slug": f"{slug_prefix}-{uuid4()}",
|
||||||
|
"description": "Reusable editorial workflow for article production.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||||
|
workflow = create_response.json()["workflow"]
|
||||||
|
workflow_id = workflow["id"]
|
||||||
|
|
||||||
|
for stage_payload in (
|
||||||
|
self._intake_stage_payload(),
|
||||||
|
self._draft_stage_payload(),
|
||||||
|
):
|
||||||
|
stage_response = self.client.post(
|
||||||
|
f"/api/admin/workflows/{workflow_id}/stages",
|
||||||
|
headers=self._admin_headers(),
|
||||||
|
json=stage_payload,
|
||||||
|
)
|
||||||
|
self.assertEqual(201, stage_response.status_code, stage_response.text)
|
||||||
|
|
||||||
|
activate_response = self.client.post(
|
||||||
|
f"/api/admin/workflows/{workflow_id}/activate",
|
||||||
|
headers=self._admin_headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual(200, activate_response.status_code, activate_response.text)
|
||||||
|
workflow = activate_response.json()["workflow"]
|
||||||
|
self.assertEqual("ACTIVE", workflow["status"])
|
||||||
|
self.assertEqual(2, workflow["version"])
|
||||||
|
return workflow
|
||||||
|
|
||||||
|
def _first_target_site(self) -> dict[str, Any]:
|
||||||
|
response = self.client.get("/api/sites", headers=self._editor_headers())
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
return response.json()[0]["site"]
|
||||||
|
|
||||||
|
def _intake_stage_payload(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"stable_key": "intake-boundary-questions",
|
||||||
|
"display_name": "Boundary question intake",
|
||||||
|
"description": "Collect required editorial context before planning.",
|
||||||
|
"position": 1,
|
||||||
|
"owner_role": "EDITOR",
|
||||||
|
"runner_profile_key": "boundary-question-agent-v1",
|
||||||
|
"required_inputs": [
|
||||||
|
"article_brief",
|
||||||
|
"target_site",
|
||||||
|
],
|
||||||
|
"expected_outputs": [
|
||||||
|
"answered_boundary_questions",
|
||||||
|
],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"All required boundary questions are answered.",
|
||||||
|
],
|
||||||
|
"requires_human_approval": True,
|
||||||
|
"retry_policy": {
|
||||||
|
"max_attempts": 1,
|
||||||
|
"backoff_seconds": 0,
|
||||||
|
},
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
"key": "required-context-checklist",
|
||||||
|
"type": "checklist",
|
||||||
|
"title": "Required context checklist",
|
||||||
|
"payload": {
|
||||||
|
"prompt": "Identify missing audience and source constraints.",
|
||||||
|
},
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Missing required context is listed explicitly.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _draft_stage_payload(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"stable_key": "draft-assembly",
|
||||||
|
"display_name": "Draft assembly",
|
||||||
|
"description": "Generate a first draft from the approved plan.",
|
||||||
|
"position": 2,
|
||||||
|
"owner_role": "EDITOR",
|
||||||
|
"runner_profile_key": "draft-writer-v1",
|
||||||
|
"required_inputs": [
|
||||||
|
"approved_plan",
|
||||||
|
],
|
||||||
|
"expected_outputs": [
|
||||||
|
"article_draft",
|
||||||
|
],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Draft follows the approved plan.",
|
||||||
|
],
|
||||||
|
"requires_human_approval": False,
|
||||||
|
"retry_policy": {
|
||||||
|
"max_attempts": 2,
|
||||||
|
"backoff_seconds": 60,
|
||||||
|
},
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
"key": "draft-outline",
|
||||||
|
"type": "outline",
|
||||||
|
"title": "Draft outline",
|
||||||
|
"payload": {
|
||||||
|
"prompt": "Build a concise outline before drafting.",
|
||||||
|
},
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Every section has a purpose.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _admin_headers(self) -> dict[str, str]:
|
||||||
|
return {DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}
|
||||||
|
|
||||||
|
def _editor_headers(self) -> dict[str, str]:
|
||||||
|
return {DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
FROM node:22-alpine
|
FROM node:22-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY apps/frontend/package.json ./package.json
|
RUN corepack enable
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
COPY apps/frontend ./
|
COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
COPY apps/frontend/package.json ./apps/frontend/package.json
|
||||||
|
COPY packages/shared/package.json ./packages/shared/package.json
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY apps/frontend ./apps/frontend
|
||||||
|
COPY packages/shared ./packages/shared
|
||||||
|
|
||||||
|
WORKDIR /app/apps/frontend
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["npm", "run", "dev", "--", "--hostname", "0.0.0.0"]
|
CMD ["pnpm", "exec", "next", "dev", "--hostname", "0.0.0.0"]
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
outputFileTracingRoot: repoRoot,
|
||||||
|
async rewrites() {
|
||||||
|
const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8000";
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/:path*",
|
||||||
|
destination: `${backendUrl}/api/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
turbopack: {
|
||||||
|
root: repoRoot,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -4,9 +4,13 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start"
|
"start": "next start",
|
||||||
|
"test:roles": "node tests/role-navigation.test.mjs",
|
||||||
|
"test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs && node tests/admin_workflows.model.test.mjs && node tests/agent_jobs.model.test.mjs && node tests/boundary_questions.model.test.mjs && node tests/plan_review.model.test.mjs && node tests/research_manifest.model.test.mjs && node tests/evidence_matrix.model.test.mjs",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@pipeline/shared": "workspace:*",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0"
|
"react-dom": "19.0.0"
|
||||||
@@ -17,6 +21,10 @@
|
|||||||
"@types/react-dom": "19.0.2",
|
"@types/react-dom": "19.0.2",
|
||||||
"typescript": "5.7.2"
|
"typescript": "5.7.2"
|
||||||
},
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@next/swc-linux-arm64-gnu": "16.2.6",
|
||||||
|
"@next/swc-linux-x64-gnu": "16.2.6"
|
||||||
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"postcss": "8.5.14"
|
"postcss": "8.5.14"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import AdminJobsPage from "@/pages/admin-jobs";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AdminJobsPage />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import AdminScriptsPage from "@/pages/admin-scripts";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AdminScriptsPage />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import AdminSitesPage from "@/pages/admin-sites";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AdminSitesPage />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import AdminWorkflowsPage from "@/pages/admin-workflows";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AdminWorkflowsPage />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import ArticleBoundaryQuestionsPage from "@/pages/article-boundary-questions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type DynamicArticleBoundaryQuestionsPageProps = {
|
||||||
|
params: Promise<{
|
||||||
|
articleId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function DynamicArticleBoundaryQuestionsPage({
|
||||||
|
params,
|
||||||
|
}: DynamicArticleBoundaryQuestionsPageProps) {
|
||||||
|
const { articleId } = await params;
|
||||||
|
return <ArticleBoundaryQuestionsPage articleId={articleId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import ArticleEvidencePage from "@/pages/article-evidence";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type DynamicArticleEvidencePageProps = {
|
||||||
|
params: Promise<{
|
||||||
|
articleId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function DynamicArticleEvidencePage({
|
||||||
|
params,
|
||||||
|
}: DynamicArticleEvidencePageProps) {
|
||||||
|
const { articleId } = await params;
|
||||||
|
return <ArticleEvidencePage articleId={articleId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import ArticleDetailPage from "@/pages/article-detail";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type DynamicArticleDetailPageProps = {
|
||||||
|
params: Promise<{
|
||||||
|
articleId: string;
|
||||||
|
}>;
|
||||||
|
searchParams?: Promise<{
|
||||||
|
role?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function DynamicArticleDetailPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: DynamicArticleDetailPageProps) {
|
||||||
|
const { articleId } = await params;
|
||||||
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
|
const viewerRoleHint = resolvedSearchParams?.role === "admin" ? "admin" : "editor";
|
||||||
|
return <ArticleDetailPage articleId={articleId} viewerRoleHint={viewerRoleHint} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import ArticlePlansPage from "@/pages/article-plans";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type DynamicArticlePlansPageProps = {
|
||||||
|
params: Promise<{
|
||||||
|
articleId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function DynamicArticlePlansPage({
|
||||||
|
params,
|
||||||
|
}: DynamicArticlePlansPageProps) {
|
||||||
|
const { articleId } = await params;
|
||||||
|
return <ArticlePlansPage articleId={articleId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import ArticleResearchPage from "@/pages/article-research";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type DynamicArticleResearchPageProps = {
|
||||||
|
params: Promise<{
|
||||||
|
articleId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function DynamicArticleResearchPage({
|
||||||
|
params,
|
||||||
|
}: DynamicArticleResearchPageProps) {
|
||||||
|
const { articleId } = await params;
|
||||||
|
return <ArticleResearchPage articleId={articleId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import NewArticlePage from "@/pages/article-new";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <NewArticlePage />;
|
||||||
|
}
|
||||||
@@ -19,3 +19,414 @@ main {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 48px;
|
padding: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pageHeader {
|
||||||
|
display: flex;
|
||||||
|
max-width: 920px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageHeader h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageHeader p {
|
||||||
|
margin: 0;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 32px;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #b7c0cc;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #344054;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleNavigation {
|
||||||
|
display: grid;
|
||||||
|
max-width: 920px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleNavigation a {
|
||||||
|
display: flex;
|
||||||
|
min-height: 52px;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #d0d5dd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1d2733;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
max-width: 980px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d0d5dd;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
border-bottom: 1px solid #d0d5dd;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
background: #eef2f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table a {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f4b99;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px dashed #b7c0cc;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.articleForm {
|
||||||
|
max-width: 620px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #d0d5dd;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.articleForm label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.articleForm input,
|
||||||
|
.articleForm select,
|
||||||
|
.articleForm textarea {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #d0d5dd;
|
||||||
|
padding: 10px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.articleForm textarea {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeArea {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
min-height: 120px;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldError {
|
||||||
|
margin: 0;
|
||||||
|
color: #b42318;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldHint {
|
||||||
|
margin: 0;
|
||||||
|
color: #475467;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formSuccess {
|
||||||
|
margin: 0;
|
||||||
|
color: #027a48;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formError {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: #b42318;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inlineLinks {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inlineLinks a {
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid #d0d5dd;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inlineLinks .activeLink {
|
||||||
|
border-color: #1f4b99;
|
||||||
|
color: #1f4b99;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table button {
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #1d2733;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1d2733;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableAction {
|
||||||
|
background: #eceff5 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formActions {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formActions button {
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #1d2733;
|
||||||
|
background: #1d2733;
|
||||||
|
color: #ffffff;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader h2,
|
||||||
|
.sectionHeader p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader button {
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #1d2733;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1d2733;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boundaryQuestions {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questionList {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questionItem {
|
||||||
|
border: 1px solid #d0d5dd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questionHeader {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questionHeader p {
|
||||||
|
margin: 6px 0 10px;
|
||||||
|
color: #475467;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questionItem textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 96px;
|
||||||
|
border: 1px solid #d0d5dd;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requiredBadge,
|
||||||
|
.optionalBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 28px;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requiredBadge {
|
||||||
|
border: 1px solid #b42318;
|
||||||
|
color: #b42318;
|
||||||
|
background: #fff4f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionalBadge {
|
||||||
|
border: 1px solid #667085;
|
||||||
|
color: #344054;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailGrid div {
|
||||||
|
border: 1px solid #d0d5dd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailGrid dt {
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #667085;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailGrid dd {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
max-width: 980px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline li {
|
||||||
|
border-bottom: 1px solid #d0d5dd;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timelineMeta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timelineSource {
|
||||||
|
border: 1px solid #d0d5dd;
|
||||||
|
border-radius: 6px;
|
||||||
|
min-height: 24px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draftEditor {
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewPanel {
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draftEditor .detailGrid select {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #d0d5dd;
|
||||||
|
padding: 10px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewPane {
|
||||||
|
border: 1px solid #d0d5dd;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewImage {
|
||||||
|
margin: 0;
|
||||||
|
border: 1px dashed #d0d5dd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewImage img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewImage figcaption {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewActions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(88px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewActions input {
|
||||||
|
grid-column: 1 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
main {
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageHeader {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export const metadata = {
|
|||||||
description: "Internal editorial workflow foundation",
|
description: "Internal editorial workflow foundation",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
export default function HomePage() {
|
import DashboardPage from "@/pages/dashboard";
|
||||||
return (
|
|
||||||
<main>
|
export const dynamic = "force-dynamic";
|
||||||
<h1>AI Content Pipeline</h1>
|
|
||||||
<p>Dockerized monorepo foundation is running.</p>
|
export default function Page() {
|
||||||
</main>
|
return <DashboardPage />;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user