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
@@ -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",