Task 008 implement boundary questions loop

This commit is contained in:
2026-05-21 19:46:26 +03:00
parent 8aa92ea728
commit b7e2fa34b2
27 changed files with 1883 additions and 24 deletions
+2
View File
@@ -62,10 +62,12 @@ def get_article_detail(
article = repository.articles.get(article_id)
target_site = repository.target_sites.get_by_id(article.target_site_id)
workflow_events = repository.articles.list_workflow_events(article_id)
boundary_questions = repository.boundary_questions.list_for_article(article_id)
return ArticleDetailResponse(
article=article,
target_site=target_site,
workflow_events=workflow_events,
boundary_questions=boundary_questions,
)
@@ -0,0 +1,265 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import UUID
from src.domain.contracts import (
AgentJobErrorCategory,
AgentJobListResponse,
AgentJobStatus,
AgentJobType,
ArticleSummary,
ArticleWorkflowStatus,
BoundaryQuestionListResponse,
BoundaryQuestionResponse,
BoundaryQuestionUpdateRequest,
CurrentUser,
)
BOUNDARY_QUESTION_FIXTURE: tuple[dict[str, object], ...] = (
{
"category": "audience",
"label": "Audience",
"is_required": True,
},
{
"category": "purpose",
"label": "Purpose",
"is_required": True,
},
{
"category": "reader_outcome",
"label": "Reader outcome",
"is_required": True,
},
{
"category": "depth",
"label": "Depth",
"is_required": True,
},
{
"category": "tone",
"label": "Tone",
"is_required": True,
},
{
"category": "excluded_topics",
"label": "Excluded topics",
"is_required": True,
},
{
"category": "primary_keyword",
"label": "Primary keyword",
"is_required": True,
},
{
"category": "competitor_angle",
"label": "Competitor angle",
"is_required": True,
},
{
"category": "evidence_standard",
"label": "Evidence standard",
"is_required": True,
},
{
"category": "visual_expectations",
"label": "Visual expectations",
"is_required": False,
},
)
def generate_boundary_questions(
repository: object,
*,
article_id: UUID,
current_user: CurrentUser,
) -> BoundaryQuestionListResponse:
article = repository.articles.get(article_id)
target_site = repository.target_sites.get_by_id(article.target_site_id)
now = _now()
previous_status = article.status
job = repository.agent_jobs.create(
article_id=article_id,
parent_job_id=None,
attempt=1,
job_type=AgentJobType.BOUNDARY_QUESTIONS,
agent_profile="fake-boundary-questions",
status=AgentJobStatus.QUEUED,
input_files=[
{
"path": "inputs/article-brief.json",
"content_hash": None,
},
{
"path": "inputs/target-site-config.json",
"content_hash": None,
},
],
queued_at=now,
)
job = repository.agent_jobs.complete(
job_id=job.id,
status=AgentJobStatus.SUCCEEDED,
workspace_path=None,
output_files=[{"path": "outputs/boundary-questions.json"}],
error_category=None,
error_message=None,
stdout="fake boundary question fixture generated\n",
stderr="",
exit_code=0,
duration_ms=0,
finished_at=now,
)
questions = repository.boundary_questions.replace_for_article(
article_id=article_id,
questions=_build_questions(article=article, target_site=target_site),
created_at=now,
)
article = repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article.id,
event_type="BOUNDARY_QUESTIONS_GENERATED",
from_status=previous_status,
to_status=ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED,
actor_user_id=current_user.id,
payload={"agent_job_id": str(job.id), "question_count": len(questions)},
created_at=now,
)
return BoundaryQuestionListResponse(questions=questions, job=job)
def list_boundary_questions(
repository: object,
*,
article_id: UUID,
) -> BoundaryQuestionListResponse:
repository.articles.get(article_id)
return BoundaryQuestionListResponse(
questions=repository.boundary_questions.list_for_article(article_id),
)
def update_boundary_question(
repository: object,
*,
article_id: UUID,
question_id: UUID,
request: BoundaryQuestionUpdateRequest,
) -> BoundaryQuestionResponse:
repository.articles.get(article_id)
answer = request.answer.strip() if request.answer is not None else None
question = repository.boundary_questions.update_answer(
article_id=article_id,
question_id=question_id,
answer=answer or None,
updated_at=_now(),
)
return BoundaryQuestionResponse(question=question)
def submit_boundary_questions(
repository: object,
*,
article_id: UUID,
current_user: CurrentUser,
) -> ArticleSummary:
article = repository.articles.get(article_id)
questions = repository.boundary_questions.list_for_article(article_id)
if not questions:
raise ValueError("Boundary questions must be generated before submission")
missing_required = [
question.category
for question in questions
if question.is_required and not (question.answer or "").strip()
]
if missing_required:
raise ValueError(
"All required boundary questions must be answered: "
+ ", ".join(missing_required)
)
now = _now()
updated_article = repository.articles.update_status(
article_id=article_id,
status=ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article_id,
event_type="BOUNDARY_ANSWERS_SUBMITTED",
from_status=article.status,
to_status=ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
actor_user_id=current_user.id,
payload={"required_answer_count": len(questions) - len(missing_required)},
created_at=now,
)
return updated_article
def create_plan_generation_job(
repository: object,
*,
article_id: UUID,
) -> AgentJobListResponse:
article = repository.articles.get(article_id)
if article.status != ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED:
raise PermissionError("Boundary answers must be submitted before plan generation")
job = repository.agent_jobs.create(
article_id=article_id,
parent_job_id=None,
attempt=1,
job_type=AgentJobType.PLAN_GENERATION,
agent_profile="fake-plan-generation",
status=AgentJobStatus.QUEUED,
input_files=[{"path": "inputs/boundary-answers.json", "content_hash": None}],
queued_at=_now(),
)
return AgentJobListResponse(jobs=[job])
def _build_questions(*, article: ArticleSummary, target_site: object) -> list[dict[str, object]]:
title = article.working_title or article.primary_keyword or article.content_type
site_audience = getattr(target_site, "audience")
brand_voice = getattr(target_site, "brand_voice")
primary_keyword = article.primary_keyword or "the primary topic"
brief = article.brief_description
prompts = {
"audience": f"Who exactly is this article for on {site_audience}?",
"purpose": f"What business or editorial purpose should '{title}' serve?",
"reader_outcome": "What should the reader be able to decide or do after reading?",
"depth": f"How deep should coverage go for this {article.content_type} brief?",
"tone": f"What tone should adapt the site voice '{brand_voice}' for this brief?",
"excluded_topics": "Which topics, claims, or comparisons must be excluded?",
"primary_keyword": f"How should the keyword '{primary_keyword}' be prioritized?",
"competitor_angle": "Which competitor angle should the article address or avoid?",
"evidence_standard": "What evidence standard is required for claims in this article?",
"visual_expectations": "What diagrams, tables, or images would clarify the brief?",
}
return [
{
"sort_order": index,
"category": str(item["category"]),
"question": f"{item['label']}: {prompts[str(item['category'])]} Brief: {brief}",
"answer": None,
"is_required": bool(item["is_required"]),
}
for index, item in enumerate(BOUNDARY_QUESTION_FIXTURE, start=1)
]
def _now() -> datetime:
return datetime.now(UTC)
@@ -25,6 +25,10 @@ from .models import (
ArticleDetailResponse,
ArticleListResponse,
ArticleSummary,
BoundaryQuestionListResponse,
BoundaryQuestionResponse,
BoundaryQuestionSummary,
BoundaryQuestionUpdateRequest,
WorkflowEventSummary,
AssetSummary,
ClaimSummary,
@@ -73,6 +77,10 @@ __all__ = [
"ArticleDetailResponse",
"ArticleListResponse",
"ArticleSummary",
"BoundaryQuestionListResponse",
"BoundaryQuestionResponse",
"BoundaryQuestionSummary",
"BoundaryQuestionUpdateRequest",
"WorkflowEventSummary",
"ArticleWorkflowStatus",
"AssetStatus",
@@ -183,6 +183,31 @@ class WorkflowEventSummary(ContractModel):
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
@@ -351,6 +376,7 @@ class ArticleDetailResponse(ContractModel):
article: ArticleSummary
target_site: TargetSiteConfig | None = None
workflow_events: list[WorkflowEventSummary] = Field(default_factory=list)
boundary_questions: list[BoundaryQuestionSummary] = Field(default_factory=list)
plan: PlanSummary | None = None
draft: DraftSummary | None = None
evidence: list[EvidenceSummary] = Field(default_factory=list)
@@ -32,6 +32,10 @@ from .models import (
ArticleDetailResponse,
ArticleListResponse,
ArticleSummary,
BoundaryQuestionListResponse,
BoundaryQuestionResponse,
BoundaryQuestionSummary,
BoundaryQuestionUpdateRequest,
AssetSummary,
ClaimSummary,
CurrentUser,
@@ -95,6 +99,10 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
ArticleCreateResponse,
ArticleListResponse,
WorkflowEventSummary,
BoundaryQuestionSummary,
BoundaryQuestionUpdateRequest,
BoundaryQuestionResponse,
BoundaryQuestionListResponse,
PlanSectionSummary,
PlanSummary,
ResearchArtifactSummary,
@@ -14,6 +14,7 @@ from src.domain.contracts import (
AgentJobSummary,
AgentJobType,
ArticleSummary,
BoundaryQuestionSummary,
PublishingRules,
PublishingStatus,
ArticleWorkflowStatus,
@@ -40,6 +41,7 @@ class BackendRepository:
self.users = UsersRepository(self)
self.target_sites = TargetSitesRepository(self)
self.articles = ArticlesRepository(self)
self.boundary_questions = BoundaryQuestionsRepository(self)
self.agent_jobs = AgentJobsRepository(self)
self.script_config_versions = ScriptConfigVersionsRepository(self)
self.script_config_version_events = ScriptConfigVersionAuditEventsRepository(self)
@@ -519,6 +521,26 @@ class ArticlesRepository:
raise LookupError(f"Article not found: {article_id}")
return _article_summary_from_row(row)
def update_status(
self,
*,
article_id: UUID,
status: ArticleWorkflowStatus,
updated_at: datetime,
) -> ArticleSummary:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
connection.execute(
f"""
UPDATE articles
SET status = {placeholder}, updated_at = {placeholder}
WHERE id = {placeholder}
""",
(status.value, _datetime_value(updated_at), str(article_id)),
)
return self.get(article_id)
def create_workflow_event(
self,
*,
@@ -593,6 +615,141 @@ class ArticlesRepository:
return [_workflow_event_from_row(row) for row in rows]
class BoundaryQuestionsRepository:
def __init__(self, repository: BackendRepository) -> None:
self._repository = repository
def replace_for_article(
self,
*,
article_id: UUID,
questions: list[JsonObject],
created_at: datetime,
) -> list[BoundaryQuestionSummary]:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
connection.execute(
f"DELETE FROM boundary_questions WHERE article_id = {placeholder}",
(str(article_id),),
)
for question in questions:
connection.execute(
f"""
INSERT INTO boundary_questions (
id,
article_id,
sort_order,
category,
question,
answer,
is_required,
created_at,
updated_at
)
VALUES (
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder}
)
""",
(
str(uuid4()),
str(article_id),
question["sort_order"],
question["category"],
question["question"],
question.get("answer"),
_bool_value(question.get("is_required", True)),
_datetime_value(created_at),
_datetime_value(created_at),
),
)
return self.list_for_article(article_id)
def list_for_article(self, article_id: UUID) -> list[BoundaryQuestionSummary]:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
rows = connection.execute(
f"""
SELECT
id,
article_id,
sort_order,
category,
question,
answer,
is_required,
created_at,
updated_at
FROM boundary_questions
WHERE article_id = {placeholder}
ORDER BY sort_order
""",
(str(article_id),),
).fetchall()
return [_boundary_question_from_row(row) for row in rows]
def get(self, *, article_id: UUID, question_id: UUID) -> BoundaryQuestionSummary:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT
id,
article_id,
sort_order,
category,
question,
answer,
is_required,
created_at,
updated_at
FROM boundary_questions
WHERE article_id = {placeholder} AND id = {placeholder}
""",
(str(article_id), str(question_id)),
).fetchone()
if row is None:
raise LookupError(f"Boundary question not found: {question_id}")
return _boundary_question_from_row(row)
def update_answer(
self,
*,
article_id: UUID,
question_id: UUID,
answer: str | None,
updated_at: datetime,
) -> BoundaryQuestionSummary:
self.get(article_id=article_id, question_id=question_id)
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
connection.execute(
f"""
UPDATE boundary_questions
SET answer = {placeholder}, updated_at = {placeholder}
WHERE article_id = {placeholder} AND id = {placeholder}
""",
(
answer,
_datetime_value(updated_at),
str(article_id),
str(question_id),
),
)
return self.get(article_id=article_id, question_id=question_id)
class AgentJobsRepository:
def __init__(self, repository: BackendRepository) -> None:
self._repository = repository
@@ -1247,6 +1404,20 @@ def _workflow_event_from_row(row: Any) -> WorkflowEventSummary:
)
def _boundary_question_from_row(row: Any) -> BoundaryQuestionSummary:
return BoundaryQuestionSummary(
id=_row_value(row, "id"),
article_id=_row_value(row, "article_id"),
sort_order=_row_value(row, "sort_order"),
category=_row_value(row, "category"),
question=_row_value(row, "question"),
answer=_row_value(row, "answer"),
is_required=bool(_row_value(row, "is_required")),
created_at=_row_value(row, "created_at"),
updated_at=_row_value(row, "updated_at"),
)
def _agent_job_summary_from_row(row: Any) -> AgentJobSummary:
return AgentJobSummary(
id=_row_value(row, "id"),
@@ -1331,6 +1502,10 @@ def _datetime_value(value: datetime | None) -> str | None:
return value.isoformat()
def _bool_value(value: bool) -> bool:
return bool(value)
def _article_status_value(value: ArticleWorkflowStatus | None) -> str | None:
if value is None:
return None
@@ -116,6 +116,7 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
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,
@@ -124,6 +125,7 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
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(),
@@ -413,6 +415,7 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
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,
+2
View File
@@ -11,12 +11,14 @@ from src.presentation.routes.agent_jobs import (
from src.presentation.routes.agent_jobs import router as agent_jobs_router
from src.presentation.routes.articles import router as articles_router
from src.presentation.routes.auth import router as auth_router
from src.presentation.routes.boundary_questions import router as boundary_questions_router
from src.presentation.routes.sites import router as sites_router
app = FastAPI(title="AI Content Pipeline Backend")
app.include_router(auth_router)
app.include_router(articles_router)
app.include_router(boundary_questions_router)
app.include_router(agent_jobs_router)
app.include_router(internal_agent_jobs_router)
app.include_router(sites_router)
@@ -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,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()
@@ -87,6 +87,10 @@ class SchemaStorageContractsIntegrationTest(unittest.TestCase):
"output_files",
}.issubset(repository.schema.list_columns("agent_jobs"))
)
self.assertIn(
"category",
repository.schema.list_columns("boundary_questions"),
)
self.assertTrue(
{
"s3_prefix",
+1 -1
View File
@@ -6,7 +6,7 @@
"build": "next build",
"start": "next start",
"test:roles": "node tests/role-navigation.test.mjs",
"test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs && node tests/agent_jobs.model.test.mjs",
"test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs && node tests/agent_jobs.model.test.mjs && node tests/boundary_questions.model.test.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -0,0 +1,14 @@
import ArticleBoundaryQuestionsPage from "@/pages/article-boundary-questions";
type DynamicArticleBoundaryQuestionsPageProps = {
params: {
articleId: string;
};
};
export default async function DynamicArticleBoundaryQuestionsPage({
params,
}: DynamicArticleBoundaryQuestionsPageProps) {
const { articleId } = params;
return <ArticleBoundaryQuestionsPage articleId={articleId} />;
}
+83
View File
@@ -224,6 +224,89 @@ main {
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));
@@ -20,14 +20,14 @@ export function buildAgentJobRows(jobs: readonly AgentJobSummary[]): AgentJobRow
id: job.id,
type: job.job_type,
status: job.status,
attempt: job.attempt,
attempt: job.attempt ?? 1,
parentJobId: job.parent_job_id ?? null,
workspacePath: job.workspace_path ?? null,
stdout: job.stdout,
stderr: job.stderr,
stdout: job.stdout ?? "",
stderr: job.stderr ?? "",
exitCode: job.exit_code ?? null,
durationMs: job.duration_ms ?? null,
outputFiles: job.output_files.map((file) => file.path).join(", "),
outputFiles: (job.output_files ?? []).map((file) => file.path).join(", "),
error: job.error_category ?? job.error_message ?? "",
}));
}
@@ -16,12 +16,15 @@ const EMPTY_ASSIGNED_EDITOR_LABEL = "Unassigned";
export function getNextActionForStatus(status: string): string {
if (status === "ARTICLE_BRIEF_CREATED") {
return "Create plan";
}
if (status === "PLAN_DRAFTED") {
return "Run boundary questions";
}
if (status === "PLAN_REVIEWED") {
if (status === "BOUNDARY_QUESTIONS_GENERATED") {
return "Submit boundary answers";
}
if (status === "BOUNDARY_ANSWERS_SUBMITTED") {
return "Create plan";
}
if (status === "PLAN_REVIEW_REQUIRED") {
return "Gather evidence";
}
return "Continue workflow";
@@ -1,3 +1,5 @@
import Link from "next/link";
import type { DetailSummary } from "./model";
type DetailShellProps = {
@@ -36,6 +38,14 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
<h3>Brief</h3>
<p>{summary.briefDescription}</p>
<ul className="inlineLinks">
<li>
<Link href={`/articles/${summary.articleId}/boundary-questions`}>
Boundary questions
</Link>
</li>
</ul>
<h3>Workflow timeline</h3>
<ul className="timeline">
{timeline.map((event) => (
@@ -0,0 +1,39 @@
import type { BoundaryQuestionSummary } from "@pipeline/shared";
export type BoundaryQuestionFormRow = {
id: string;
category: string;
question: string;
answer: string;
isRequired: boolean;
stateLabel: string;
};
export type BoundaryQuestionErrors = Record<string, string>;
export function buildBoundaryQuestionRows(
questions: readonly BoundaryQuestionSummary[],
): BoundaryQuestionFormRow[] {
return questions.map((question) => ({
id: question.id,
category: question.category,
question: question.question,
answer: question.answer ?? "",
isRequired: question.is_required ?? true,
stateLabel: question.is_required === false ? "Optional" : "Required",
}));
}
export function validateRequiredAnswers(
rows: readonly BoundaryQuestionFormRow[],
): BoundaryQuestionErrors {
const errors: BoundaryQuestionErrors = {};
for (const row of rows) {
if (row.isRequired && !row.answer.trim()) {
errors[row.id] = "Required answer";
}
}
return errors;
}
@@ -0,0 +1,157 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import type { BoundaryQuestionSummary } from "@pipeline/shared";
import { ApiError, generateBoundaryQuestions, submitBoundaryQuestions, updateBoundaryQuestion } from "@/shared/pipeline-api";
import {
buildBoundaryQuestionRows,
validateRequiredAnswers,
type BoundaryQuestionErrors,
type BoundaryQuestionFormRow,
} from "./model";
type BoundaryQuestionsEditorProps = {
articleId: string;
initialQuestions: readonly BoundaryQuestionSummary[];
};
export function BoundaryQuestionsEditor({
articleId,
initialQuestions,
}: BoundaryQuestionsEditorProps) {
const router = useRouter();
const [rows, setRows] = useState<BoundaryQuestionFormRow[]>(() =>
buildBoundaryQuestionRows(initialQuestions),
);
const [errors, setErrors] = useState<BoundaryQuestionErrors>({});
const [formMessage, setFormMessage] = useState("");
const [isBusy, setBusy] = useState(false);
const requiredProgress = useMemo(() => {
const requiredRows = rows.filter((row) => row.isRequired);
const answeredRows = requiredRows.filter((row) => row.answer.trim());
return `${answeredRows.length}/${requiredRows.length}`;
}, [rows]);
function updateRow(questionId: string, answer: string) {
setRows((current) =>
current.map((row) => (row.id === questionId ? { ...row, answer } : row)),
);
}
async function handleGenerate() {
setBusy(true);
setFormMessage("");
try {
const response = await generateBoundaryQuestions(articleId);
setRows(buildBoundaryQuestionRows(response.questions));
setErrors({});
router.refresh();
} catch (error) {
setFormMessage(error instanceof ApiError ? error.message : "Unable to generate questions");
} finally {
setBusy(false);
}
}
async function handleSave(row: BoundaryQuestionFormRow) {
setBusy(true);
setFormMessage("");
try {
const response = await updateBoundaryQuestion(articleId, row.id, {
answer: row.answer.trim() || null,
});
setRows((current) =>
current.map((candidate) =>
candidate.id === row.id
? buildBoundaryQuestionRows([response.question])[0]
: candidate,
),
);
setErrors((current) => {
const next = { ...current };
delete next[row.id];
return next;
});
} catch (error) {
setFormMessage(error instanceof ApiError ? error.message : "Unable to save answer");
} finally {
setBusy(false);
}
}
async function handleSubmit() {
const nextErrors = validateRequiredAnswers(rows);
if (Object.keys(nextErrors).length > 0) {
setErrors(nextErrors);
return;
}
setBusy(true);
setFormMessage("");
try {
await submitBoundaryQuestions(articleId);
router.push(`/articles/${articleId}`);
router.refresh();
} catch (error) {
setFormMessage(error instanceof ApiError ? error.message : "Unable to submit answers");
} finally {
setBusy(false);
}
}
return (
<section className="boundaryQuestions">
<div className="sectionHeader">
<div>
<h2>Boundary questions</h2>
<p>Required answers: {requiredProgress}</p>
</div>
<button type="button" onClick={handleGenerate} disabled={isBusy}>
{rows.length > 0 ? "Regenerate" : "Generate"}
</button>
</div>
{formMessage ? <p className="formError">Error: {formMessage}</p> : null}
{rows.length === 0 ? (
<p className="emptyState">No boundary questions generated yet.</p>
) : (
<div className="questionList">
{rows.map((row) => (
<article className="questionItem" key={row.id}>
<div className="questionHeader">
<div>
<strong>{row.category.replaceAll("_", " ")}</strong>
<p>{row.question}</p>
</div>
<span className={row.isRequired ? "requiredBadge" : "optionalBadge"}>
{row.stateLabel}
</span>
</div>
<textarea
value={row.answer}
onChange={(event) => updateRow(row.id, event.target.value)}
/>
{errors[row.id] ? <p className="fieldError">{errors[row.id]}</p> : null}
<div className="formActions">
<button type="button" onClick={() => handleSave(row)} disabled={isBusy}>
Save
</button>
</div>
</article>
))}
</div>
)}
<div className="formActions">
<button type="button" onClick={handleSubmit} disabled={isBusy || rows.length === 0}>
Submit boundary answers
</button>
</div>
</section>
);
}
@@ -0,0 +1,34 @@
import Link from "next/link";
import { BoundaryQuestionsEditor } from "@/features/boundary-questions/ui";
import { fetchBoundaryQuestions } from "@/shared/pipeline-api";
import { RoleNavigation } from "@/widgets/role-navigation";
type ArticleBoundaryQuestionsPageProps = {
articleId: string;
};
export default async function ArticleBoundaryQuestionsPage({
articleId,
}: ArticleBoundaryQuestionsPageProps) {
const response = await fetchBoundaryQuestions(articleId);
return (
<main>
<header className="pageHeader">
<div>
<h1>Boundary questions</h1>
<p>{response.questions.length} questions</p>
</div>
<Link href={`/articles/${articleId}`}>Back to article</Link>
</header>
<RoleNavigation role="EDITOR" />
<section className="panel">
<BoundaryQuestionsEditor
articleId={articleId}
initialQuestions={response.questions}
/>
</section>
</main>
);
}
+40
View File
@@ -6,6 +6,9 @@ import type {
AgentJobResponse,
AgentJobTestCodexRequest,
ArticleListResponse,
BoundaryQuestionListResponse,
BoundaryQuestionResponse,
BoundaryQuestionUpdateRequest,
CurrentUserResponse,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse,
@@ -285,3 +288,40 @@ export function createArticle(
request,
);
}
export function generateBoundaryQuestions(
articleId: string,
): Promise<BoundaryQuestionListResponse> {
return apiPost<unknown, BoundaryQuestionListResponse>(
`/api/articles/${articleId}/boundary-questions/generate`,
undefined,
);
}
export function fetchBoundaryQuestions(
articleId: string,
): Promise<BoundaryQuestionListResponse> {
return apiGet<BoundaryQuestionListResponse>(
`/api/articles/${articleId}/boundary-questions`,
);
}
export function updateBoundaryQuestion(
articleId: string,
questionId: string,
request: BoundaryQuestionUpdateRequest,
): Promise<BoundaryQuestionResponse> {
return apiPatch<BoundaryQuestionUpdateRequest, BoundaryQuestionResponse>(
`/api/articles/${articleId}/boundary-questions/${questionId}`,
request,
);
}
export function submitBoundaryQuestions(
articleId: string,
): Promise<ArticleCreateResponse> {
return apiPost<unknown, ArticleCreateResponse>(
`/api/articles/${articleId}/boundary-questions/submit`,
undefined,
);
}
@@ -59,5 +59,5 @@ const rows = buildDashboardRows(
assert.equal(rows.length, 1, "one row should be built");
assert.equal(rows[0].targetSiteName, "Editorial site", "target site name should map");
assert.equal(rows[0].nextAction, "Create plan", "next action should match status mapping");
assert.equal(rows[0].nextAction, "Run boundary questions", "next action should match status mapping");
assert.equal(rows[0].assignedEditor, "Unassigned", "missing editor id should render Unassigned");
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const testDir = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(
testDir,
"../src/features/boundary-questions/model.ts",
);
const source = readFileSync(sourcePath, "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
});
const moduleExports = {};
new Function("exports", compiled.outputText)(moduleExports);
const { buildBoundaryQuestionRows, validateRequiredAnswers } = moduleExports;
const rows = buildBoundaryQuestionRows([
{
id: "11111111-1111-1111-1111-111111111111",
article_id: "22222222-2222-2222-2222-222222222222",
sort_order: 1,
category: "audience",
question: "Audience?",
answer: "",
is_required: true,
created_at: "2026-05-21T00:00:00Z",
updated_at: "2026-05-21T00:00:00Z",
},
{
id: "33333333-3333-3333-3333-333333333333",
article_id: "22222222-2222-2222-2222-222222222222",
sort_order: 2,
category: "visual_expectations",
question: "Visuals?",
answer: "",
is_required: false,
created_at: "2026-05-21T00:00:00Z",
updated_at: "2026-05-21T00:00:00Z",
},
]);
assert.equal(rows[0].stateLabel, "Required");
assert.equal(rows[1].stateLabel, "Optional");
const errors = validateRequiredAnswers(rows);
assert.equal(errors["11111111-1111-1111-1111-111111111111"], "Required answer");
assert.equal(errors["33333333-3333-3333-3333-333333333333"], undefined);