Task 009 implement plan generation review gate

This commit is contained in:
2026-05-21 19:56:58 +03:00
parent b7e2fa34b2
commit 5b27d1a3ca
24 changed files with 2422 additions and 59 deletions
+3 -23
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from uuid import UUID, uuid4 from uuid import UUID
from src.domain.contracts import ( from src.domain.contracts import (
ArticleCreateRequest, ArticleCreateRequest,
@@ -11,10 +11,6 @@ from src.domain.contracts import (
ArticleWorkflowStatus, ArticleWorkflowStatus,
CurrentUser, CurrentUser,
PublishingStatus, PublishingStatus,
ReviewActionResponse,
ReviewStatus,
ReviewSummary,
ReviewType,
) )
@@ -63,31 +59,15 @@ def get_article_detail(
target_site = repository.target_sites.get_by_id(article.target_site_id) target_site = repository.target_sites.get_by_id(article.target_site_id)
workflow_events = repository.articles.list_workflow_events(article_id) workflow_events = repository.articles.list_workflow_events(article_id)
boundary_questions = repository.boundary_questions.list_for_article(article_id) boundary_questions = repository.boundary_questions.list_for_article(article_id)
plans = repository.article_plans.list_for_article(article_id)
return ArticleDetailResponse( return ArticleDetailResponse(
article=article, article=article,
target_site=target_site, target_site=target_site,
workflow_events=workflow_events, workflow_events=workflow_events,
boundary_questions=boundary_questions, boundary_questions=boundary_questions,
plan=plans[-1] if plans else None,
) )
def build_placeholder_plan_approval_response(
*,
article_id: UUID,
plan_id: UUID,
reviewer_id: UUID,
) -> ReviewActionResponse:
review = ReviewSummary(
id=uuid4(),
article_id=article_id,
review_type=ReviewType.PLAN,
status=ReviewStatus.APPROVED,
reviewer_id=reviewer_id,
notes=None,
created_at=_now(),
)
return ReviewActionResponse(review=review)
def _now() -> datetime: def _now() -> datetime:
return datetime.now(UTC) return datetime.now(UTC)
+330
View File
@@ -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)
@@ -37,7 +37,12 @@ from .models import (
DraftSummary, DraftSummary,
EvidenceSummary, EvidenceSummary,
PlanSectionSummary, PlanSectionSummary,
PlanSectionEditRequest,
PlanListResponse,
PlanResponse,
PlanRevisionRequest,
PlanSummary, PlanSummary,
PlanUpdateRequest,
PublishCommitSummary, PublishCommitSummary,
PublishingRules, PublishingRules,
ResearchArtifactManifestSummary, ResearchArtifactManifestSummary,
@@ -94,8 +99,13 @@ __all__ = [
"DraftSummary", "DraftSummary",
"EvidenceSummary", "EvidenceSummary",
"PlanReviewStatus", "PlanReviewStatus",
"PlanListResponse",
"PlanResponse",
"PlanRevisionRequest",
"PlanSectionEditRequest",
"PlanSectionSummary", "PlanSectionSummary",
"PlanSummary", "PlanSummary",
"PlanUpdateRequest",
"PublishCommitSummary", "PublishCommitSummary",
"PublishingRules", "PublishingRules",
"PublishingStatus", "PublishingStatus",
@@ -214,6 +214,8 @@ class PlanSectionSummary(ContractModel):
sort_order: int = Field(ge=1) sort_order: int = Field(ge=1)
heading: str = Field(min_length=1) heading: str = Field(min_length=1)
purpose: str | None = None 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) claims_to_support: list[str] = Field(default_factory=list)
target_word_count: int | None = Field(default=None, ge=1) target_word_count: int | None = Field(default=None, ge=1)
@@ -223,15 +225,63 @@ class PlanSummary(ContractModel):
article_id: UUID article_id: UUID
version: int = Field(ge=1) version: int = Field(ge=1)
status: PlanReviewStatus status: PlanReviewStatus
title_options: list[str] = Field(default_factory=list)
recommended_title: str | None = None recommended_title: str | None = None
reader_persona: str | None = None
search_intent: str | None = None search_intent: str | None = None
thesis: str | None = None thesis: str | None = None
sections: list[PlanSectionSummary] = Field(default_factory=list) sections: list[PlanSectionSummary] = Field(default_factory=list)
claims_to_prove: list[str] = 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) risks: list[str] = Field(default_factory=list)
created_at: datetime 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): class ResearchArtifactSummary(ContractModel):
artifact_type: str = Field(min_length=1) artifact_type: str = Field(min_length=1)
source_url: str = Field(min_length=1) source_url: str = Field(min_length=1)
@@ -43,7 +43,12 @@ from .models import (
DraftSummary, DraftSummary,
EvidenceSummary, EvidenceSummary,
PlanSectionSummary, PlanSectionSummary,
PlanSectionEditRequest,
PlanListResponse,
PlanResponse,
PlanRevisionRequest,
PlanSummary, PlanSummary,
PlanUpdateRequest,
PublishCommitSummary, PublishCommitSummary,
PublishingRules, PublishingRules,
ResearchArtifactManifestSummary, ResearchArtifactManifestSummary,
@@ -104,7 +109,12 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
BoundaryQuestionResponse, BoundaryQuestionResponse,
BoundaryQuestionListResponse, BoundaryQuestionListResponse,
PlanSectionSummary, PlanSectionSummary,
PlanSectionEditRequest,
PlanSummary, PlanSummary,
PlanUpdateRequest,
PlanRevisionRequest,
PlanResponse,
PlanListResponse,
ResearchArtifactSummary, ResearchArtifactSummary,
ResearchArtifactManifestSummary, ResearchArtifactManifestSummary,
EvidenceSummary, EvidenceSummary,
@@ -15,6 +15,9 @@ from src.domain.contracts import (
AgentJobType, AgentJobType,
ArticleSummary, ArticleSummary,
BoundaryQuestionSummary, BoundaryQuestionSummary,
PlanReviewStatus,
PlanSectionSummary,
PlanSummary,
PublishingRules, PublishingRules,
PublishingStatus, PublishingStatus,
ArticleWorkflowStatus, ArticleWorkflowStatus,
@@ -42,6 +45,7 @@ class BackendRepository:
self.target_sites = TargetSitesRepository(self) self.target_sites = TargetSitesRepository(self)
self.articles = ArticlesRepository(self) self.articles = ArticlesRepository(self)
self.boundary_questions = BoundaryQuestionsRepository(self) self.boundary_questions = BoundaryQuestionsRepository(self)
self.article_plans = ArticlePlansRepository(self)
self.agent_jobs = AgentJobsRepository(self) self.agent_jobs = AgentJobsRepository(self)
self.script_config_versions = ScriptConfigVersionsRepository(self) self.script_config_versions = ScriptConfigVersionsRepository(self)
self.script_config_version_events = ScriptConfigVersionAuditEventsRepository(self) self.script_config_version_events = ScriptConfigVersionAuditEventsRepository(self)
@@ -750,6 +754,257 @@ class BoundaryQuestionsRepository:
return self.get(article_id=article_id, question_id=question_id) return self.get(article_id=article_id, question_id=question_id)
class ArticlePlansRepository:
def __init__(self, repository: BackendRepository) -> None:
self._repository = repository
def create_version(
self,
*,
article_id: UUID,
version: int,
status: PlanReviewStatus,
title_options: list[str],
recommended_title: str | None,
reader_persona: str | None,
search_intent: str | None,
thesis: str | None,
claims_to_prove: list[str],
evidence_needs: list[str],
visual_needs: list[str],
seo_notes: list[str],
source_requirements: list[str],
excluded_sources: list[str],
tone: str | None,
audience: str | None,
risks: list[str],
sections: list[JsonObject],
created_at: datetime,
) -> PlanSummary:
plan_id = uuid4()
placeholder = self._repository.placeholder()
json_cast = self._repository.json_cast()
with self._repository.connection() as connection:
connection.execute(
f"""
INSERT INTO article_plans (
id,
article_id,
version,
status,
title_options,
recommended_title,
reader_persona,
search_intent,
thesis,
claims_to_prove,
evidence_needs,
visual_needs,
seo_notes,
source_requirements,
excluded_sources,
tone,
audience,
risks,
created_at
)
VALUES (
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder}{json_cast},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder}{json_cast},
{placeholder}{json_cast},
{placeholder}{json_cast},
{placeholder}{json_cast},
{placeholder}{json_cast},
{placeholder}{json_cast},
{placeholder},
{placeholder},
{placeholder}{json_cast},
{placeholder}
)
""",
(
str(plan_id),
str(article_id),
version,
status.value,
_json_value(title_options),
recommended_title,
reader_persona,
search_intent,
thesis,
_json_value(claims_to_prove),
_json_value(evidence_needs),
_json_value(visual_needs),
_json_value(seo_notes),
_json_value(source_requirements),
_json_value(excluded_sources),
tone,
audience,
_json_value(risks),
_datetime_value(created_at),
),
)
for index, section in enumerate(sections, start=1):
connection.execute(
f"""
INSERT INTO plan_sections (
id,
article_plan_id,
sort_order,
heading,
purpose,
key_points,
evidence_needs,
claims_to_support,
target_word_count
)
VALUES (
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder}{json_cast},
{placeholder}{json_cast},
{placeholder}{json_cast},
{placeholder}
)
""",
(
str(uuid4()),
str(plan_id),
index,
section["heading"],
section.get("purpose"),
_json_value(section.get("key_points", [])),
_json_value(section.get("evidence_needs", [])),
_json_value(section.get("claims_to_support", [])),
section.get("target_word_count"),
),
)
return self.get(article_id=article_id, plan_id=plan_id)
def list_for_article(self, article_id: UUID) -> list[PlanSummary]:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
rows = connection.execute(
f"""
SELECT {self._select_columns()}
FROM article_plans
WHERE article_id = {placeholder}
ORDER BY version
""",
(str(article_id),),
).fetchall()
return [self._with_sections(row) for row in rows]
def get(self, *, article_id: UUID, plan_id: UUID) -> PlanSummary:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT {self._select_columns()}
FROM article_plans
WHERE article_id = {placeholder} AND id = {placeholder}
""",
(str(article_id), str(plan_id)),
).fetchone()
if row is None:
raise LookupError(f"Plan not found: {plan_id}")
return self._with_sections(row)
def latest_version(self, article_id: UUID) -> int:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT COALESCE(MAX(version), 0) AS version
FROM article_plans
WHERE article_id = {placeholder}
""",
(str(article_id),),
).fetchone()
return int(_row_value(row, "version"))
def update_status(
self,
*,
article_id: UUID,
plan_id: UUID,
status: PlanReviewStatus,
) -> PlanSummary:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
connection.execute(
f"""
UPDATE article_plans
SET status = {placeholder}
WHERE article_id = {placeholder} AND id = {placeholder}
""",
(status.value, str(article_id), str(plan_id)),
)
return self.get(article_id=article_id, plan_id=plan_id)
def _with_sections(self, row: Any) -> PlanSummary:
plan_id = _row_value(row, "id")
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
section_rows = connection.execute(
f"""
SELECT
id,
article_plan_id,
sort_order,
heading,
purpose,
key_points,
evidence_needs,
claims_to_support,
target_word_count
FROM plan_sections
WHERE article_plan_id = {placeholder}
ORDER BY sort_order
""",
(str(plan_id),),
).fetchall()
return _plan_summary_from_row(row, [_plan_section_from_row(item) for item in section_rows])
def _select_columns(self) -> str:
return """
id,
article_id,
version,
status,
title_options,
recommended_title,
reader_persona,
search_intent,
thesis,
claims_to_prove,
evidence_needs,
visual_needs,
seo_notes,
source_requirements,
excluded_sources,
tone,
audience,
risks,
created_at
"""
class AgentJobsRepository: class AgentJobsRepository:
def __init__(self, repository: BackendRepository) -> None: def __init__(self, repository: BackendRepository) -> None:
self._repository = repository self._repository = repository
@@ -1418,6 +1673,48 @@ def _boundary_question_from_row(row: Any) -> BoundaryQuestionSummary:
) )
def _plan_section_from_row(row: Any) -> PlanSectionSummary:
return PlanSectionSummary(
id=_row_value(row, "id"),
article_plan_id=_row_value(row, "article_plan_id"),
sort_order=_row_value(row, "sort_order"),
heading=_row_value(row, "heading"),
purpose=_row_value(row, "purpose"),
key_points=_json_from_row(row, "key_points"),
evidence_needs=_json_from_row(row, "evidence_needs"),
claims_to_support=_json_from_row(row, "claims_to_support"),
target_word_count=_row_value(row, "target_word_count"),
)
def _plan_summary_from_row(
row: Any,
sections: list[PlanSectionSummary],
) -> PlanSummary:
return PlanSummary(
id=_row_value(row, "id"),
article_id=_row_value(row, "article_id"),
version=_row_value(row, "version"),
status=_row_value(row, "status"),
title_options=_json_from_row(row, "title_options"),
recommended_title=_row_value(row, "recommended_title"),
reader_persona=_row_value(row, "reader_persona"),
search_intent=_row_value(row, "search_intent"),
thesis=_row_value(row, "thesis"),
sections=sections,
claims_to_prove=_json_from_row(row, "claims_to_prove"),
evidence_needs=_json_from_row(row, "evidence_needs"),
visual_needs=_json_from_row(row, "visual_needs"),
seo_notes=_json_from_row(row, "seo_notes"),
source_requirements=_json_from_row(row, "source_requirements"),
excluded_sources=_json_from_row(row, "excluded_sources"),
tone=_row_value(row, "tone"),
audience=_row_value(row, "audience"),
risks=_json_from_row(row, "risks"),
created_at=_row_value(row, "created_at"),
)
def _agent_job_summary_from_row(row: Any) -> AgentJobSummary: def _agent_job_summary_from_row(row: Any) -> AgentJobSummary:
return AgentJobSummary( return AgentJobSummary(
id=_row_value(row, "id"), id=_row_value(row, "id"),
+33
View File
@@ -132,15 +132,33 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE, article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
version INTEGER NOT NULL, version INTEGER NOT NULL,
status TEXT NOT NULL CHECK (status IN ({PLAN_REVIEW_STATUS_VALUES})), status TEXT NOT NULL CHECK (status IN ({PLAN_REVIEW_STATUS_VALUES})),
title_options JSONB NOT NULL DEFAULT '[]'::jsonb,
recommended_title TEXT, recommended_title TEXT,
reader_persona TEXT,
search_intent TEXT, search_intent TEXT,
thesis TEXT, thesis TEXT,
claims_to_prove JSONB NOT NULL DEFAULT '[]'::jsonb, 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, risks JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (article_id, version) 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 ( CREATE TABLE IF NOT EXISTS plan_sections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -148,11 +166,15 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
sort_order INTEGER NOT NULL, sort_order INTEGER NOT NULL,
heading TEXT NOT NULL, heading TEXT NOT NULL,
purpose TEXT, purpose TEXT,
key_points JSONB NOT NULL DEFAULT '[]'::jsonb,
evidence_needs JSONB NOT NULL DEFAULT '[]'::jsonb,
claims_to_support JSONB NOT NULL DEFAULT '[]'::jsonb, claims_to_support JSONB NOT NULL DEFAULT '[]'::jsonb,
target_word_count INTEGER, target_word_count INTEGER,
UNIQUE (article_plan_id, sort_order) 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 ( CREATE TABLE IF NOT EXISTS evidence_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -430,10 +452,19 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
article_id TEXT NOT NULL, article_id TEXT NOT NULL,
version INTEGER NOT NULL, version INTEGER NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
title_options TEXT NOT NULL DEFAULT '[]',
recommended_title TEXT, recommended_title TEXT,
reader_persona TEXT,
search_intent TEXT, search_intent TEXT,
thesis TEXT, thesis TEXT,
claims_to_prove TEXT NOT NULL DEFAULT '[]', 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 '[]', risks TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (article_id, version) UNIQUE (article_id, version)
@@ -446,6 +477,8 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
sort_order INTEGER NOT NULL, sort_order INTEGER NOT NULL,
heading TEXT NOT NULL, heading TEXT NOT NULL,
purpose TEXT, purpose TEXT,
key_points TEXT NOT NULL DEFAULT '[]',
evidence_needs TEXT NOT NULL DEFAULT '[]',
claims_to_support TEXT NOT NULL DEFAULT '[]', claims_to_support TEXT NOT NULL DEFAULT '[]',
target_word_count INTEGER, target_word_count INTEGER,
UNIQUE (article_plan_id, sort_order) UNIQUE (article_plan_id, sort_order)
+2
View File
@@ -12,6 +12,7 @@ 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.articles import router as articles_router
from src.presentation.routes.auth import router as auth_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.boundary_questions import router as boundary_questions_router
from src.presentation.routes.plans import router as plans_router
from src.presentation.routes.sites import router as sites_router from src.presentation.routes.sites import router as sites_router
@@ -19,6 +20,7 @@ app = FastAPI(title="AI Content Pipeline Backend")
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(articles_router) app.include_router(articles_router)
app.include_router(boundary_questions_router) app.include_router(boundary_questions_router)
app.include_router(plans_router)
app.include_router(agent_jobs_router) app.include_router(agent_jobs_router)
app.include_router(internal_agent_jobs_router) app.include_router(internal_agent_jobs_router)
app.include_router(sites_router) app.include_router(sites_router)
@@ -8,7 +8,6 @@ from src.application.articles import (
create_article, create_article,
get_article_detail, get_article_detail,
list_articles, list_articles,
build_placeholder_plan_approval_response,
) )
from src.domain.auth import EDITOR_OR_ADMIN_ROLES from src.domain.auth import EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import ( from src.domain.contracts import (
@@ -17,7 +16,6 @@ from src.domain.contracts import (
ArticleDetailResponse, ArticleDetailResponse,
ArticleListResponse, ArticleListResponse,
CurrentUser, CurrentUser,
ReviewActionResponse,
) )
from src.infrastructure.repositories import BackendRepository from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles from src.presentation.dependencies import get_repository, require_roles
@@ -73,19 +71,3 @@ def get_article(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Not Found", detail="Not Found",
) from error ) from error
@router.post(
"/articles/{article_id}/plans/{plan_id}/approve",
response_model=ReviewActionResponse,
)
def approve_plan(
article_id: UUID,
plan_id: UUID,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
) -> ReviewActionResponse:
return build_placeholder_plan_approval_response(
article_id=article_id,
plan_id=plan_id,
reviewer_id=current_user.id,
)
@@ -0,0 +1,159 @@
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,
start_research,
)
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
AgentJobListResponse,
CurrentUser,
PlanListResponse,
PlanResponse,
PlanRevisionRequest,
PlanUpdateRequest,
ReviewActionResponse,
)
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=AgentJobListResponse,
status_code=status.HTTP_202_ACCEPTED,
)
def post_start_research(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AgentJobListResponse:
try:
return start_research(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,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(202, allowed_research_response.status_code, allowed_research_response.text)
self.assertEqual("RESEARCH", allowed_research_response.json()["jobs"][0]["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()
@@ -91,6 +91,24 @@ class SchemaStorageContractsIntegrationTest(unittest.TestCase):
"category", "category",
repository.schema.list_columns("boundary_questions"), 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( self.assertTrue(
{ {
"s3_prefix", "s3_prefix",
+1 -1
View File
@@ -6,7 +6,7 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"test:roles": "node tests/role-navigation.test.mjs", "test:roles": "node tests/role-navigation.test.mjs",
"test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs && node tests/agent_jobs.model.test.mjs && node tests/boundary_questions.model.test.mjs", "test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs && node tests/agent_jobs.model.test.mjs && node tests/boundary_questions.model.test.mjs && node tests/plan_review.model.test.mjs",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
@@ -0,0 +1,14 @@
import ArticlePlansPage from "@/pages/article-plans";
type DynamicArticlePlansPageProps = {
params: {
articleId: string;
};
};
export default async function DynamicArticlePlansPage({
params,
}: DynamicArticlePlansPageProps) {
const { articleId } = params;
return <ArticlePlansPage articleId={articleId} />;
}
@@ -44,6 +44,9 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
Boundary questions Boundary questions
</Link> </Link>
</li> </li>
<li>
<Link href={`/articles/${summary.articleId}/plans`}>Plan review</Link>
</li>
</ul> </ul>
<h3>Workflow timeline</h3> <h3>Workflow timeline</h3>
@@ -0,0 +1,37 @@
import type { PlanSummary } from "@pipeline/shared";
export type PlanReviewView = {
id: string;
version: number;
status: string;
recommendedTitle: string;
readerPersona: string;
searchIntent: string;
sectionCount: number;
evidenceNeeds: string;
visualNeeds: string;
seoNotes: string;
sourceRequirements: string;
excludedSources: string;
tone: string;
audience: string;
};
export function buildPlanReviewViews(plans: readonly PlanSummary[]): PlanReviewView[] {
return plans.map((plan) => ({
id: plan.id,
version: plan.version,
status: plan.status,
recommendedTitle: plan.recommended_title ?? "",
readerPersona: plan.reader_persona ?? "",
searchIntent: plan.search_intent ?? "",
sectionCount: plan.sections?.length ?? 0,
evidenceNeeds: (plan.evidence_needs ?? []).join(", "),
visualNeeds: (plan.visual_needs ?? []).join(", "),
seoNotes: (plan.seo_notes ?? []).join(", "),
sourceRequirements: (plan.source_requirements ?? []).join(", "),
excludedSources: (plan.excluded_sources ?? []).join(", "),
tone: plan.tone ?? "",
audience: plan.audience ?? "",
}));
}
@@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { PlanSummary } from "@pipeline/shared";
import {
ApiError,
approveArticlePlan,
generateArticlePlan,
requestArticlePlanRevision,
updateArticlePlan,
} from "@/shared/pipeline-api";
import { buildPlanReviewViews } from "./model";
type PlanReviewPanelProps = {
articleId: string;
initialPlans: readonly PlanSummary[];
};
export function PlanReviewPanel({ articleId, initialPlans }: PlanReviewPanelProps) {
const router = useRouter();
const [plans, setPlans] = useState<PlanSummary[]>([...initialPlans]);
const [message, setMessage] = useState("");
const [isBusy, setBusy] = useState(false);
const views = buildPlanReviewViews(plans);
const currentPlan = plans.at(-1) ?? null;
async function runAction(action: () => Promise<unknown>) {
setBusy(true);
setMessage("");
try {
await action();
router.refresh();
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Plan action failed");
} finally {
setBusy(false);
}
}
function handleGenerate() {
void runAction(async () => {
const response = await generateArticlePlan(articleId);
setPlans((current) => [...current, response.plan]);
});
}
function handleEdit() {
if (!currentPlan) {
return;
}
void runAction(async () => {
const response = await updateArticlePlan(articleId, currentPlan.id, {
recommended_title: `${currentPlan.recommended_title ?? "Plan"} edited`,
tone: currentPlan.tone ?? "Evidence-led",
audience: currentPlan.audience ?? currentPlan.reader_persona,
seo_notes: [...(currentPlan.seo_notes ?? []), "Editor adjustment"],
source_requirements: currentPlan.source_requirements ?? [],
excluded_sources: currentPlan.excluded_sources ?? [],
visual_needs: currentPlan.visual_needs ?? [],
});
setPlans((current) => [...current, response.plan]);
});
}
function handleRevision() {
if (!currentPlan) {
return;
}
void runAction(() =>
requestArticlePlanRevision(articleId, currentPlan.id, {
notes: "Revise plan scope and evidence standard.",
}),
);
}
function handleApprove() {
if (!currentPlan) {
return;
}
void runAction(() => approveArticlePlan(articleId, currentPlan.id));
}
return (
<section>
<div className="sectionHeader">
<div>
<h2>Plan review</h2>
<p>{plans.length} versions</p>
</div>
<button type="button" onClick={handleGenerate} disabled={isBusy}>
Generate
</button>
</div>
{message ? <p className="formError">Error: {message}</p> : null}
{views.length === 0 ? (
<p className="emptyState">No plans generated yet.</p>
) : (
<table className="table">
<thead>
<tr>
<th>Version</th>
<th>Plan</th>
<th>Requirements</th>
<th>Controls</th>
</tr>
</thead>
<tbody>
{views.map((view) => (
<tr key={view.id}>
<td>
<strong>v{view.version}</strong>
<div>{view.status}</div>
</td>
<td>
<div>{view.recommendedTitle}</div>
<div>{view.readerPersona}</div>
<div>{view.searchIntent}</div>
<div>{view.sectionCount} sections</div>
</td>
<td>
<div>Sources: {view.sourceRequirements}</div>
<div>Excluded: {view.excludedSources}</div>
<div>Visuals: {view.visualNeeds}</div>
<div>SEO: {view.seoNotes}</div>
</td>
<td>
<div>Tone: {view.tone}</div>
<div>Audience: {view.audience}</div>
</td>
</tr>
))}
</tbody>
</table>
)}
<div className="formActions">
<button type="button" onClick={handleEdit} disabled={isBusy || !currentPlan}>
Edit
</button>
<button type="button" onClick={handleRevision} disabled={isBusy || !currentPlan}>
Request revision
</button>
<button type="button" onClick={handleApprove} disabled={isBusy || !currentPlan}>
Approve
</button>
</div>
</section>
);
}
@@ -0,0 +1,29 @@
import Link from "next/link";
import { PlanReviewPanel } from "@/features/plan-review/ui";
import { fetchArticlePlans } from "@/shared/pipeline-api";
import { RoleNavigation } from "@/widgets/role-navigation";
type ArticlePlansPageProps = {
articleId: string;
};
export default async function ArticlePlansPage({ articleId }: ArticlePlansPageProps) {
const response = await fetchArticlePlans(articleId);
return (
<main>
<header className="pageHeader">
<div>
<h1>Plan review</h1>
<p>{response.plans.length} versions</p>
</div>
<Link href={`/articles/${articleId}`}>Back to article</Link>
</header>
<RoleNavigation role="EDITOR" />
<section className="panel">
<PlanReviewPanel articleId={articleId} initialPlans={response.plans} />
</section>
</main>
);
}
+48
View File
@@ -10,6 +10,11 @@ import type {
BoundaryQuestionResponse, BoundaryQuestionResponse,
BoundaryQuestionUpdateRequest, BoundaryQuestionUpdateRequest,
CurrentUserResponse, CurrentUserResponse,
PlanListResponse,
PlanResponse,
PlanRevisionRequest,
PlanUpdateRequest,
ReviewActionResponse,
ScriptConfigVersionCreateRequest, ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse, ScriptConfigVersionListResponse,
ScriptConfigVersionResponse, ScriptConfigVersionResponse,
@@ -325,3 +330,46 @@ export function submitBoundaryQuestions(
undefined, undefined,
); );
} }
export function generateArticlePlan(articleId: string): Promise<PlanResponse> {
return apiPost<unknown, PlanResponse>(
`/api/articles/${articleId}/plan/generate`,
undefined,
);
}
export function fetchArticlePlans(articleId: string): Promise<PlanListResponse> {
return apiGet<PlanListResponse>(`/api/articles/${articleId}/plans`);
}
export function updateArticlePlan(
articleId: string,
planId: string,
request: PlanUpdateRequest,
): Promise<PlanResponse> {
return apiPatch<PlanUpdateRequest, PlanResponse>(
`/api/articles/${articleId}/plans/${planId}`,
request,
);
}
export function approveArticlePlan(
articleId: string,
planId: string,
): Promise<ReviewActionResponse> {
return apiPost<unknown, ReviewActionResponse>(
`/api/articles/${articleId}/plans/${planId}/approve`,
undefined,
);
}
export function requestArticlePlanRevision(
articleId: string,
planId: string,
request: PlanRevisionRequest,
): Promise<ReviewActionResponse> {
return apiPost<PlanRevisionRequest, ReviewActionResponse>(
`/api/articles/${articleId}/plans/${planId}/request-revision`,
request,
);
}
@@ -0,0 +1,50 @@
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/plan-review/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 { buildPlanReviewViews } = moduleExports;
const rows = buildPlanReviewViews([
{
id: "11111111-1111-1111-1111-111111111111",
article_id: "22222222-2222-2222-2222-222222222222",
version: 2,
status: "PENDING_REVIEW",
title_options: ["A", "B"],
recommended_title: "Recommended",
reader_persona: "Editors",
search_intent: "Compare",
thesis: "Thesis",
sections: [{ id: "s", article_plan_id: "p", sort_order: 1, heading: "H" }],
claims_to_prove: [],
evidence_needs: ["Docs"],
visual_needs: ["Diagram"],
seo_notes: ["Keyword"],
source_requirements: ["Primary"],
excluded_sources: ["Forums"],
tone: "Direct",
audience: "Technical editors",
risks: [],
created_at: "2026-05-21T00:00:00Z",
},
]);
assert.equal(rows[0].version, 2);
assert.equal(rows[0].recommendedTitle, "Recommended");
assert.equal(rows[0].sectionCount, 1);
assert.equal(rows[0].sourceRequirements, "Primary");
assert.equal(rows[0].excludedSources, "Forums");
assert.equal(rows[0].visualNeeds, "Diagram");
+817 -3
View File
@@ -1169,6 +1169,36 @@
"title": "HTTPValidationError", "title": "HTTPValidationError",
"type": "object" "type": "object"
}, },
"PlanListResponse": {
"additionalProperties": false,
"properties": {
"plans": {
"items": {
"$ref": "#/components/schemas/PlanSummary"
},
"title": "Plans",
"type": "array"
}
},
"required": [
"plans"
],
"title": "PlanListResponse",
"type": "object"
},
"PlanResponse": {
"additionalProperties": false,
"properties": {
"plan": {
"$ref": "#/components/schemas/PlanSummary"
}
},
"required": [
"plan"
],
"title": "PlanResponse",
"type": "object"
},
"PlanReviewStatus": { "PlanReviewStatus": {
"enum": [ "enum": [
"PENDING_REVIEW", "PENDING_REVIEW",
@@ -1178,6 +1208,82 @@
"title": "PlanReviewStatus", "title": "PlanReviewStatus",
"type": "string" "type": "string"
}, },
"PlanRevisionRequest": {
"additionalProperties": false,
"properties": {
"notes": {
"minLength": 1,
"title": "Notes",
"type": "string"
}
},
"required": [
"notes"
],
"title": "PlanRevisionRequest",
"type": "object"
},
"PlanSectionEditRequest": {
"additionalProperties": false,
"properties": {
"claims_to_support": {
"items": {
"type": "string"
},
"title": "Claims To Support",
"type": "array"
},
"evidence_needs": {
"items": {
"type": "string"
},
"title": "Evidence Needs",
"type": "array"
},
"heading": {
"minLength": 1,
"title": "Heading",
"type": "string"
},
"key_points": {
"items": {
"type": "string"
},
"title": "Key Points",
"type": "array"
},
"purpose": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Purpose"
},
"target_word_count": {
"anyOf": [
{
"minimum": 1,
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"title": "Target Word Count"
}
},
"required": [
"heading"
],
"title": "PlanSectionEditRequest",
"type": "object"
},
"PlanSectionSummary": { "PlanSectionSummary": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -1193,6 +1299,13 @@
"title": "Claims To Support", "title": "Claims To Support",
"type": "array" "type": "array"
}, },
"evidence_needs": {
"items": {
"type": "string"
},
"title": "Evidence Needs",
"type": "array"
},
"heading": { "heading": {
"minLength": 1, "minLength": 1,
"title": "Heading", "title": "Heading",
@@ -1203,6 +1316,13 @@
"title": "Id", "title": "Id",
"type": "string" "type": "string"
}, },
"key_points": {
"items": {
"type": "string"
},
"title": "Key Points",
"type": "array"
},
"purpose": { "purpose": {
"anyOf": [ "anyOf": [
{ {
@@ -1251,6 +1371,18 @@
"title": "Article Id", "title": "Article Id",
"type": "string" "type": "string"
}, },
"audience": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Audience"
},
"claims_to_prove": { "claims_to_prove": {
"items": { "items": {
"type": "string" "type": "string"
@@ -1263,11 +1395,37 @@
"title": "Created At", "title": "Created At",
"type": "string" "type": "string"
}, },
"evidence_needs": {
"items": {
"type": "string"
},
"title": "Evidence Needs",
"type": "array"
},
"excluded_sources": {
"items": {
"type": "string"
},
"title": "Excluded Sources",
"type": "array"
},
"id": { "id": {
"format": "uuid", "format": "uuid",
"title": "Id", "title": "Id",
"type": "string" "type": "string"
}, },
"reader_persona": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Reader Persona"
},
"recommended_title": { "recommended_title": {
"anyOf": [ "anyOf": [
{ {
@@ -1306,6 +1464,20 @@
"title": "Sections", "title": "Sections",
"type": "array" "type": "array"
}, },
"seo_notes": {
"items": {
"type": "string"
},
"title": "Seo Notes",
"type": "array"
},
"source_requirements": {
"items": {
"type": "string"
},
"title": "Source Requirements",
"type": "array"
},
"status": { "status": {
"$ref": "#/components/schemas/PlanReviewStatus" "$ref": "#/components/schemas/PlanReviewStatus"
}, },
@@ -1321,10 +1493,36 @@
"default": null, "default": null,
"title": "Thesis" "title": "Thesis"
}, },
"title_options": {
"items": {
"type": "string"
},
"title": "Title Options",
"type": "array"
},
"tone": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Tone"
},
"version": { "version": {
"minimum": 1, "minimum": 1,
"title": "Version", "title": "Version",
"type": "integer" "type": "integer"
},
"visual_needs": {
"items": {
"type": "string"
},
"title": "Visual Needs",
"type": "array"
} }
}, },
"required": [ "required": [
@@ -1337,6 +1535,220 @@
"title": "PlanSummary", "title": "PlanSummary",
"type": "object" "type": "object"
}, },
"PlanUpdateRequest": {
"additionalProperties": false,
"properties": {
"audience": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Audience"
},
"claims_to_prove": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Claims To Prove"
},
"evidence_needs": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Evidence Needs"
},
"excluded_sources": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Excluded Sources"
},
"reader_persona": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Reader Persona"
},
"recommended_title": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Recommended Title"
},
"risks": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Risks"
},
"search_intent": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Search Intent"
},
"sections": {
"anyOf": [
{
"items": {
"$ref": "#/components/schemas/PlanSectionEditRequest"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Sections"
},
"seo_notes": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Seo Notes"
},
"source_requirements": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Source Requirements"
},
"thesis": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Thesis"
},
"title_options": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Title Options"
},
"tone": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Tone"
},
"visual_needs": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Visual Needs"
}
},
"title": "PlanUpdateRequest",
"type": "object"
},
"PublishCommitSummary": { "PublishCommitSummary": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -3068,6 +3480,124 @@
] ]
} }
}, },
"/api/articles/{article_id}/plan/generate": {
"post": {
"operationId": "post_generate_plan_api_articles__article_id__plan_generate_post",
"parameters": [
{
"in": "path",
"name": "article_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Article Id",
"type": "string"
}
},
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PlanResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Post Generate Plan",
"tags": [
"plans"
]
}
},
"/api/articles/{article_id}/plans": {
"get": {
"operationId": "get_plans_api_articles__article_id__plans_get",
"parameters": [
{
"in": "path",
"name": "article_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Article Id",
"type": "string"
}
},
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PlanListResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Get Plans",
"tags": [
"plans"
]
}
},
"/api/articles/{article_id}/plans/generate": { "/api/articles/{article_id}/plans/generate": {
"post": { "post": {
"operationId": "post_generate_plan_api_articles__article_id__plans_generate_post", "operationId": "post_generate_plan_api_articles__article_id__plans_generate_post",
@@ -3127,9 +3657,155 @@
] ]
} }
}, },
"/api/articles/{article_id}/plans/{plan_id}": {
"get": {
"operationId": "get_plan_route_api_articles__article_id__plans__plan_id__get",
"parameters": [
{
"in": "path",
"name": "article_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Article Id",
"type": "string"
}
},
{
"in": "path",
"name": "plan_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Plan Id",
"type": "string"
}
},
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PlanResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Get Plan Route",
"tags": [
"plans"
]
},
"patch": {
"operationId": "patch_plan_api_articles__article_id__plans__plan_id__patch",
"parameters": [
{
"in": "path",
"name": "article_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Article Id",
"type": "string"
}
},
{
"in": "path",
"name": "plan_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Plan Id",
"type": "string"
}
},
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PlanUpdateRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PlanResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Patch Plan",
"tags": [
"plans"
]
}
},
"/api/articles/{article_id}/plans/{plan_id}/approve": { "/api/articles/{article_id}/plans/{plan_id}/approve": {
"post": { "post": {
"operationId": "approve_plan_api_articles__article_id__plans__plan_id__approve_post", "operationId": "post_approve_plan_api_articles__article_id__plans__plan_id__approve_post",
"parameters": [ "parameters": [
{ {
"in": "path", "in": "path",
@@ -3190,9 +3866,147 @@
"description": "Validation Error" "description": "Validation Error"
} }
}, },
"summary": "Approve Plan", "summary": "Post Approve Plan",
"tags": [ "tags": [
"articles" "plans"
]
}
},
"/api/articles/{article_id}/plans/{plan_id}/request-revision": {
"post": {
"operationId": "post_request_plan_revision_api_articles__article_id__plans__plan_id__request_revision_post",
"parameters": [
{
"in": "path",
"name": "article_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Article Id",
"type": "string"
}
},
{
"in": "path",
"name": "plan_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Plan Id",
"type": "string"
}
},
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PlanRevisionRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReviewActionResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Post Request Plan Revision",
"tags": [
"plans"
]
}
},
"/api/articles/{article_id}/research/start": {
"post": {
"operationId": "post_start_research_api_articles__article_id__research_start_post",
"parameters": [
{
"in": "path",
"name": "article_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Article Id",
"type": "string"
}
},
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"responses": {
"202": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AgentJobListResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Post Start Research",
"tags": [
"plans"
] ]
} }
}, },
+50
View File
@@ -197,13 +197,36 @@ export type HTTPValidationError = {
detail?: ValidationError[]; detail?: ValidationError[];
}; };
export type PlanListResponse = {
plans: PlanSummary[];
};
export type PlanResponse = {
plan: PlanSummary;
};
export type PlanReviewStatus = "PENDING_REVIEW" | "APPROVED" | "REVISION_REQUESTED"; export type PlanReviewStatus = "PENDING_REVIEW" | "APPROVED" | "REVISION_REQUESTED";
export type PlanRevisionRequest = {
notes: string;
};
export type PlanSectionEditRequest = {
claims_to_support?: string[];
evidence_needs?: string[];
heading: string;
key_points?: string[];
purpose?: string | null;
target_word_count?: number | null;
};
export type PlanSectionSummary = { export type PlanSectionSummary = {
article_plan_id: string; article_plan_id: string;
claims_to_support?: string[]; claims_to_support?: string[];
evidence_needs?: string[];
heading: string; heading: string;
id: string; id: string;
key_points?: string[];
purpose?: string | null; purpose?: string | null;
sort_order: number; sort_order: number;
target_word_count?: number | null; target_word_count?: number | null;
@@ -211,16 +234,43 @@ export type PlanSectionSummary = {
export type PlanSummary = { export type PlanSummary = {
article_id: string; article_id: string;
audience?: string | null;
claims_to_prove?: string[]; claims_to_prove?: string[];
created_at: string; created_at: string;
evidence_needs?: string[];
excluded_sources?: string[];
id: string; id: string;
reader_persona?: string | null;
recommended_title?: string | null; recommended_title?: string | null;
risks?: string[]; risks?: string[];
search_intent?: string | null; search_intent?: string | null;
sections?: PlanSectionSummary[]; sections?: PlanSectionSummary[];
seo_notes?: string[];
source_requirements?: string[];
status: PlanReviewStatus; status: PlanReviewStatus;
thesis?: string | null; thesis?: string | null;
title_options?: string[];
tone?: string | null;
version: number; version: number;
visual_needs?: string[];
};
export type PlanUpdateRequest = {
audience?: string | null;
claims_to_prove?: string[] | null;
evidence_needs?: string[] | null;
excluded_sources?: string[] | null;
reader_persona?: string | null;
recommended_title?: string | null;
risks?: string[] | null;
search_intent?: string | null;
sections?: PlanSectionEditRequest[] | null;
seo_notes?: string[] | null;
source_requirements?: string[] | null;
thesis?: string | null;
title_options?: string[] | null;
tone?: string | null;
visual_needs?: string[] | null;
}; };
export type PublishCommitSummary = { export type PublishCommitSummary = {
+14 -14
View File
@@ -39,14 +39,14 @@ Development description: Implement article plan generation, immutable plan versi
## Acceptance Criteria ## Acceptance Criteria
- [ ] TDD pre-requirement: before implementation, write one failing public API test showing research cannot start before plan approval; implement generation/review behavior in vertical cycles and record evidence in `Result`. - [x] TDD pre-requirement: before implementation, write one failing public API test showing research cannot start before plan approval; implement generation/review behavior in vertical cycles and record evidence in `Result`.
- [ ] Plan generation creates version 1 and moves article to `PLAN_REVIEW_REQUIRED`. - [x] Plan generation creates version 1 and moves article to `PLAN_REVIEW_REQUIRED`.
- [ ] Plan has at least four sections with purpose, key points, and evidence needs. - [x] Plan has at least four sections with purpose, key points, and evidence needs.
- [ ] Direct plan edit creates a new immutable version. - [x] Direct plan edit creates a new immutable version.
- [ ] Revision request writes workflow event and returns workflow to plan generation/revision. - [x] Revision request writes workflow event and returns workflow to plan generation/revision.
- [ ] Approval writes workflow event with actor, timestamp, and exact plan version. - [x] Approval writes workflow event with actor, timestamp, and exact plan version.
- [ ] Previous plan versions remain accessible. - [x] Previous plan versions remain accessible.
- [ ] Frontend supports approve, request revision, direct edit, section notes, source requirements, excluded sources, visual requirements, tone, audience, and SEO keyword changes. - [x] Frontend supports approve, request revision, direct edit, section notes, source requirements, excluded sources, visual requirements, tone, audience, and SEO keyword changes.
## Verification ## Verification
@@ -56,9 +56,9 @@ Development description: Implement article plan generation, immutable plan versi
## Result ## Result
- Status: Pending execution. - Status: Accepted.
- TDD plan: To be filled during execution. - TDD plan: Added `apps/backend/tests/integration/test_plan_generation_review_public_api.py` before implementation. The test starts from an article with submitted boundary answers, asserts research start is blocked before approval, then verifies plan generation, immutable edit versioning, revision request, approval event payload, and research unlock.
- Red evidence: To be filled during execution. - Red evidence: Initial run failed with `409 != 404` for `POST /api/articles/{article_id}/research/start`, proving the public research gate was absent.
- Green evidence: To be filled during execution. - Green evidence: Implemented plan contracts, schema fields, repository persistence, plan generation/review routes, research start gate, generated shared contracts, and FSD plan review frontend at `/articles/[articleId]/plans`.
- Refactor notes: To be filled during execution. - Refactor notes: Replaced the placeholder plan approval route with real plan lookup/status transitions. Added immutable plan versions and section-level key points/evidence needs while preserving previous versions in `GET /plans`.
- Verification output: To be filled during execution. - Verification output: `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_plan_generation_review_public_api.py apps/backend/tests/integration/test_boundary_questions_public_api.py apps/backend/tests/integration/test_schema_storage_contracts.py apps/backend/tests/contracts/test_generated_contract_artifacts.py` passed. `pnpm --filter @pipeline/frontend test:ui` passed. `pnpm --filter @pipeline/frontend typecheck` passed. `tests/smoke/plan-flow.sh` passed with Docker Compose after Docker socket escalation.
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
COMPOSE_FILE="${ROOT_DIR}/docker-compose.yml"
BACKEND_URL="${BACKEND_URL:-http://localhost:8000}"
WAIT_SECONDS="${WAIT_SECONDS:-60}"
PYTHON_BIN="${PYTHON_BIN:-python3}"
EDITOR_EMAIL="${EDITOR_EMAIL:-editor@example.com}"
cleanup() {
docker compose -f "${COMPOSE_FILE}" down --remove-orphans >/dev/null 2>&1 || true
}
request() {
local method="$1"
local path="$2"
local body="${3:-}"
if [[ -n "${body}" ]]; then
curl -fsS -X "${method}" -H "Content-Type: application/json" \
-H "X-Demo-User-Email: ${EDITOR_EMAIL}" --data "${body}" "${BACKEND_URL}${path}"
return
fi
curl -fsS -X "${method}" -H "X-Demo-User-Email: ${EDITOR_EMAIL}" "${BACKEND_URL}${path}"
}
wait_backend() {
local deadline=$((SECONDS + WAIT_SECONDS))
while (( SECONDS < deadline )); do
if curl -fsS "${BACKEND_URL}/health" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
echo "Timed out waiting for backend at ${BACKEND_URL}" >&2
return 1
}
json_eval() {
local expression="$1"
"${PYTHON_BIN}" -c "import json,sys; print(${expression})"
}
cd "${ROOT_DIR}"
trap cleanup EXIT
docker compose -f "${COMPOSE_FILE}" up --build -d
wait_backend
sites_json="$(request GET /api/sites)"
site_id="$(printf '%s' "${sites_json}" | json_eval "json.load(sys.stdin)[0]['site']['id']")"
article_body="$(
SITE_ID="${site_id}" "${PYTHON_BIN}" - <<'PY'
import json
import os
import uuid
print(json.dumps({
"target_site_id": os.environ["SITE_ID"],
"brief_description": f"Plan smoke flow {uuid.uuid4()}",
"working_title": "Plan Smoke Flow",
"content_type": "longform_guide",
"primary_keyword": "plan smoke flow",
}))
PY
)"
article_json="$(request POST /api/articles "${article_body}")"
article_id="$(printf '%s' "${article_json}" | json_eval "json.load(sys.stdin)['article']['id']")"
questions_json="$(request POST "/api/articles/${article_id}/boundary-questions/generate")"
question_patch_commands="$(
QUESTIONS_JSON="${questions_json}" "${PYTHON_BIN}" - <<'PY'
import json
import os
payload = json.loads(os.environ["QUESTIONS_JSON"])
for question in payload["questions"]:
if question["is_required"]:
print(f"{question['id']}\tAccepted answer for {question['category']}.")
PY
)"
while IFS=$'\t' read -r question_id answer; do
[[ -z "${question_id}" ]] && continue
patch_body="$(ANSWER="${answer}" "${PYTHON_BIN}" - <<'PY'
import json
import os
print(json.dumps({"answer": os.environ["ANSWER"]}))
PY
)"
request PATCH "/api/articles/${article_id}/boundary-questions/${question_id}" "${patch_body}" >/dev/null
done <<< "${question_patch_commands}"
request POST "/api/articles/${article_id}/boundary-questions/submit" >/dev/null
plan_json="$(request POST "/api/articles/${article_id}/plan/generate")"
plan_id="$(printf '%s' "${plan_json}" | json_eval "json.load(sys.stdin)['plan']['id']")"
edit_body="$("${PYTHON_BIN}" - <<'PY'
import json
print(json.dumps({
"recommended_title": "Edited smoke plan",
"tone": "Direct",
"audience": "Editors",
"seo_notes": ["Smoke keyword"],
}))
PY
)"
edited_json="$(request PATCH "/api/articles/${article_id}/plans/${plan_id}" "${edit_body}")"
edited_plan_id="$(printf '%s' "${edited_json}" | json_eval "json.load(sys.stdin)['plan']['id']")"
request POST "/api/articles/${article_id}/plans/${edited_plan_id}/request-revision" '{"notes":"Smoke revision"}' >/dev/null
request POST "/api/articles/${article_id}/plans/${edited_plan_id}/approve" >/dev/null
research_json="$(request POST "/api/articles/${article_id}/research/start")"
job_type="$(printf '%s' "${research_json}" | json_eval "json.load(sys.stdin)['jobs'][0]['job_type']")"
if [[ "${job_type}" != "RESEARCH" ]]; then
echo "Expected RESEARCH job, got ${job_type}" >&2
exit 1
fi
echo "plan flow smoke ok"