diff --git a/apps/backend/src/application/articles.py b/apps/backend/src/application/articles.py index f4bce40..c2adc93 100644 --- a/apps/backend/src/application/articles.py +++ b/apps/backend/src/application/articles.py @@ -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, ) diff --git a/apps/backend/src/application/boundary_questions.py b/apps/backend/src/application/boundary_questions.py new file mode 100644 index 0000000..79a4e00 --- /dev/null +++ b/apps/backend/src/application/boundary_questions.py @@ -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) diff --git a/apps/backend/src/domain/contracts/__init__.py b/apps/backend/src/domain/contracts/__init__.py index cb2035b..ffe00dd 100644 --- a/apps/backend/src/domain/contracts/__init__.py +++ b/apps/backend/src/domain/contracts/__init__.py @@ -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", diff --git a/apps/backend/src/domain/contracts/models.py b/apps/backend/src/domain/contracts/models.py index 16e1bfa..be8b834 100644 --- a/apps/backend/src/domain/contracts/models.py +++ b/apps/backend/src/domain/contracts/models.py @@ -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) diff --git a/apps/backend/src/domain/contracts/openapi.py b/apps/backend/src/domain/contracts/openapi.py index 9f53bba..1d9ddfc 100644 --- a/apps/backend/src/domain/contracts/openapi.py +++ b/apps/backend/src/domain/contracts/openapi.py @@ -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, diff --git a/apps/backend/src/infrastructure/repositories.py b/apps/backend/src/infrastructure/repositories.py index 903f3ce..a6e0938 100644 --- a/apps/backend/src/infrastructure/repositories.py +++ b/apps/backend/src/infrastructure/repositories.py @@ -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 diff --git a/apps/backend/src/infrastructure/schema.py b/apps/backend/src/infrastructure/schema.py index d848610..dfc18b0 100644 --- a/apps/backend/src/infrastructure/schema.py +++ b/apps/backend/src/infrastructure/schema.py @@ -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, diff --git a/apps/backend/src/presentation/main.py b/apps/backend/src/presentation/main.py index b89493c..377b50a 100644 --- a/apps/backend/src/presentation/main.py +++ b/apps/backend/src/presentation/main.py @@ -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) diff --git a/apps/backend/src/presentation/routes/boundary_questions.py b/apps/backend/src/presentation/routes/boundary_questions.py new file mode 100644 index 0000000..a98c516 --- /dev/null +++ b/apps/backend/src/presentation/routes/boundary_questions.py @@ -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 diff --git a/apps/backend/tests/integration/test_boundary_questions_public_api.py b/apps/backend/tests/integration/test_boundary_questions_public_api.py new file mode 100644 index 0000000..b6127c1 --- /dev/null +++ b/apps/backend/tests/integration/test_boundary_questions_public_api.py @@ -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() diff --git a/apps/backend/tests/integration/test_schema_storage_contracts.py b/apps/backend/tests/integration/test_schema_storage_contracts.py index 5d29de6..9a8d98a 100644 --- a/apps/backend/tests/integration/test_schema_storage_contracts.py +++ b/apps/backend/tests/integration/test_schema_storage_contracts.py @@ -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", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 932f6cf..06c0189 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -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": { diff --git a/apps/frontend/src/app/articles/[articleId]/boundary-questions/page.tsx b/apps/frontend/src/app/articles/[articleId]/boundary-questions/page.tsx new file mode 100644 index 0000000..80d14f9 --- /dev/null +++ b/apps/frontend/src/app/articles/[articleId]/boundary-questions/page.tsx @@ -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 ; +} diff --git a/apps/frontend/src/app/globals.css b/apps/frontend/src/app/globals.css index 7b1cd48..7664f05 100644 --- a/apps/frontend/src/app/globals.css +++ b/apps/frontend/src/app/globals.css @@ -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)); diff --git a/apps/frontend/src/features/agent-jobs/model.ts b/apps/frontend/src/features/agent-jobs/model.ts index a859a3d..46d1692 100644 --- a/apps/frontend/src/features/agent-jobs/model.ts +++ b/apps/frontend/src/features/agent-jobs/model.ts @@ -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 ?? "", })); } diff --git a/apps/frontend/src/features/article-dashboard/model.ts b/apps/frontend/src/features/article-dashboard/model.ts index 2ec7733..61cd61c 100644 --- a/apps/frontend/src/features/article-dashboard/model.ts +++ b/apps/frontend/src/features/article-dashboard/model.ts @@ -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"; diff --git a/apps/frontend/src/features/article-detail/ui.tsx b/apps/frontend/src/features/article-detail/ui.tsx index e784e20..5cd67e2 100644 --- a/apps/frontend/src/features/article-detail/ui.tsx +++ b/apps/frontend/src/features/article-detail/ui.tsx @@ -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) {

Brief

{summary.briefDescription}

+ +

Workflow timeline