From c3914bf8bee89fb5b9fa5b85a0f4e8d83d657176 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 21 May 2026 23:33:15 +0300 Subject: [PATCH] feat(task-013): add draft assembly API, immutable versioning, and editor preview --- apps/backend/src/application/articles.py | 2 + apps/backend/src/application/drafts.py | 451 ++++++++++++++++ apps/backend/src/domain/contracts/__init__.py | 10 + apps/backend/src/domain/contracts/models.py | 35 ++ apps/backend/src/domain/contracts/openapi.py | 10 + .../src/infrastructure/repositories.py | 207 ++++++++ apps/backend/src/infrastructure/schema.py | 18 + apps/backend/src/presentation/main.py | 2 + .../backend/src/presentation/routes/drafts.py | 93 ++++ .../test_draft_assembly_public_api.py | 395 ++++++++++++++ apps/frontend/src/app/globals.css | 38 ++ .../src/features/article-draft/model.ts | 170 ++++++ .../src/features/article-draft/ui.tsx | 299 +++++++++++ .../src/pages/article-detail/index.tsx | 9 +- apps/frontend/src/shared/pipeline-api.ts | 33 ++ .../tests/draft_editor.model.test.mjs | 75 +++ packages/shared/openapi.json | 500 ++++++++++++++++++ packages/shared/src/api-types.ts | 37 +- tasks/013-draft-assembly-editor-preview.md | 48 +- 19 files changed, 2416 insertions(+), 16 deletions(-) create mode 100644 apps/backend/src/application/drafts.py create mode 100644 apps/backend/src/presentation/routes/drafts.py create mode 100644 apps/backend/tests/integration/test_draft_assembly_public_api.py create mode 100644 apps/frontend/src/features/article-draft/model.ts create mode 100644 apps/frontend/src/features/article-draft/ui.tsx create mode 100644 apps/frontend/tests/draft_editor.model.test.mjs diff --git a/apps/backend/src/application/articles.py b/apps/backend/src/application/articles.py index b99f3ea..7b5adea 100644 --- a/apps/backend/src/application/articles.py +++ b/apps/backend/src/application/articles.py @@ -60,6 +60,7 @@ def get_article_detail( workflow_events = repository.articles.list_workflow_events(article_id) boundary_questions = repository.boundary_questions.list_for_article(article_id) plans = repository.article_plans.list_for_article(article_id) + draft = repository.article_drafts.latest_for_article(article_id) research_manifests = repository.research_manifests.list_for_article(article_id) evidence = repository.evidence_items.list_for_article(article_id) claims = repository.claims.list_for_article(article_id) @@ -70,6 +71,7 @@ def get_article_detail( workflow_events=workflow_events, boundary_questions=boundary_questions, plan=plans[-1] if plans else None, + draft=draft, evidence=evidence, claims=claims, agent_jobs=agent_jobs, diff --git a/apps/backend/src/application/drafts.py b/apps/backend/src/application/drafts.py new file mode 100644 index 0000000..19506e4 --- /dev/null +++ b/apps/backend/src/application/drafts.py @@ -0,0 +1,451 @@ +from __future__ import annotations + +import re +from datetime import UTC, datetime +from typing import Any +from uuid import UUID + +from src.domain.contracts import ( + AgentJobStatus, + AgentJobSummary, + AgentJobType, + ArticleWorkflowStatus, + DraftAssembleResponse, + DraftFaqItem, + DraftListResponse, + DraftResponse, + DraftUpdateRequest, + PlanReviewStatus, +) + + +def assemble_draft(repository: object, *, article_id: UUID) -> DraftAssembleResponse: + article = repository.articles.get(article_id) + approved_plan = _approved_plan(repository, article_id=article_id) + if approved_plan is None or not approved_plan.sections: + raise PermissionError("Approved plan with sections is required before draft assembly") + + section_jobs = repository.agent_jobs.list_for_article(article_id) + successful_jobs_by_section = _successful_jobs_by_section(section_jobs) + missing_section_headings = [ + section.heading + for section in approved_plan.sections + if str(section.id) not in successful_jobs_by_section + ] + if missing_section_headings: + raise ValueError( + "Missing successful section scaffolds for sections: " + + ", ".join(missing_section_headings) + ) + + title = ( + approved_plan.recommended_title + or article.working_title + or article.primary_keyword + or "Untitled draft" + ) + meta_title = title + meta_description = _trim_description(article.brief_description or title) + + section_blocks: list[str] = [] + visual_placeholders: list[str] = [] + evidence_references: list[str] = [] + unsupported_warnings = _collect_job_warnings(section_jobs) + + for section in approved_plan.sections: + section_job = successful_jobs_by_section[str(section.id)] + payload = section_job.payload + section_blocks.append(_section_markdown(section.heading, payload)) + visual_placeholders.extend( + _visual_placeholders_for_section( + section_id=section.id, + heading=section.heading, + payload=payload, + ) + ) + evidence_references.extend(_string_list(payload.get("used_evidence_ids"))) + + deduped_evidence_references = _dedupe_strings(evidence_references) + deduped_unsupported_warnings = _dedupe_strings(unsupported_warnings) + deduped_visual_placeholders = _dedupe_strings(visual_placeholders) + faq_items = _build_faq_items( + claims_to_prove=approved_plan.claims_to_prove, + keyword=article.primary_keyword, + ) + + draft_body = _assemble_body_markdown( + title=title, + meta_title=meta_title, + meta_description=meta_description, + section_blocks=section_blocks, + evidence_references=deduped_evidence_references, + visual_placeholders=deduped_visual_placeholders, + faq_items=faq_items, + unsupported_warnings=deduped_unsupported_warnings, + ) + now = _now() + next_version = repository.article_drafts.latest_version(article_id) + 1 + draft = repository.article_drafts.create_version( + article_id=article_id, + version=next_version, + title=title, + slug=_slugify(title), + meta_title=meta_title, + meta_description=meta_description, + body_object_key=f"articles/{article_id}/drafts/v{next_version}.md", + body_markdown=draft_body, + faq_items=[item.model_dump(mode="json") for item in faq_items], + visual_placeholders=deduped_visual_placeholders, + evidence_references=[UUID(item_id) for item_id in deduped_evidence_references], + unsupported_claim_warnings=deduped_unsupported_warnings, + based_on_draft_id=None, + status=ArticleWorkflowStatus.DRAFT_ASSEMBLED, + created_at=now, + updated_at=now, + ) + + repository.articles.update_status( + article_id=article_id, + status=ArticleWorkflowStatus.DRAFT_ASSEMBLED, + updated_at=now, + ) + repository.articles.create_workflow_event( + article_id=article_id, + event_type="DRAFT_ASSEMBLED", + from_status=article.status, + to_status=ArticleWorkflowStatus.DRAFT_ASSEMBLED, + actor_user_id=None, + payload={ + "draft_id": str(draft.id), + "version": draft.version, + "section_count": len(approved_plan.sections), + }, + created_at=now, + ) + return DraftAssembleResponse(draft=draft) + + +def list_drafts(repository: object, *, article_id: UUID) -> DraftListResponse: + repository.articles.get(article_id) + return DraftListResponse(drafts=repository.article_drafts.list_for_article(article_id)) + + +def get_draft(repository: object, *, article_id: UUID, draft_id: UUID) -> DraftResponse: + repository.articles.get(article_id) + draft = repository.article_drafts.get(article_id=article_id, draft_id=draft_id) + return DraftResponse(draft=draft) + + +def update_draft( + repository: object, + *, + article_id: UUID, + draft_id: UUID, + request: DraftUpdateRequest, +) -> DraftResponse: + repository.articles.get(article_id) + base = repository.article_drafts.get(article_id=article_id, draft_id=draft_id) + if _request_is_empty(request): + raise ValueError("Draft update payload is empty") + + next_version = repository.article_drafts.latest_version(article_id) + 1 + now = _now() + title = request.title if request.title is not None else base.title + draft = repository.article_drafts.create_version( + article_id=article_id, + version=next_version, + title=title, + slug=_slugify(title), + meta_title=( + request.meta_title if request.meta_title is not None else base.meta_title + ), + meta_description=( + request.meta_description + if request.meta_description is not None + else base.meta_description + ), + body_object_key=f"articles/{article_id}/drafts/v{next_version}.md", + body_markdown=( + request.body_markdown + if request.body_markdown is not None + else base.body_markdown + ), + faq_items=[ + item.model_dump(mode="json") + for item in ( + request.faq_items + if request.faq_items is not None + else base.faq_items + ) + ], + visual_placeholders=( + request.visual_placeholders + if request.visual_placeholders is not None + else base.visual_placeholders + ), + evidence_references=( + request.evidence_references + if request.evidence_references is not None + else base.evidence_references + ), + unsupported_claim_warnings=( + request.unsupported_claim_warnings + if request.unsupported_claim_warnings is not None + else base.unsupported_claim_warnings + ), + based_on_draft_id=base.id, + status=base.status, + created_at=now, + updated_at=now, + ) + repository.articles.create_workflow_event( + article_id=article_id, + event_type="DRAFT_VERSION_CREATED", + from_status=ArticleWorkflowStatus.DRAFT_ASSEMBLED, + to_status=ArticleWorkflowStatus.DRAFT_ASSEMBLED, + actor_user_id=None, + payload={ + "draft_id": str(draft.id), + "version": draft.version, + "based_on_draft_id": str(base.id), + }, + created_at=now, + ) + return DraftResponse(draft=draft) + + +def _approved_plan(repository: object, *, article_id: UUID) -> object | None: + plans = repository.article_plans.list_for_article(article_id) + approved_plans = [ + plan for plan in plans if plan.status == PlanReviewStatus.APPROVED + ] + if not approved_plans: + return None + approved_plans.sort(key=lambda plan: plan.version) + return approved_plans[-1] + + +def _successful_jobs_by_section( + jobs: list[AgentJobSummary], +) -> dict[str, AgentJobSummary]: + sections: dict[str, AgentJobSummary] = {} + for job in jobs: + if job.job_type != AgentJobType.SECTION_SCAFFOLD: + continue + if job.status != AgentJobStatus.SUCCEEDED: + continue + section_id = _string_value(job.payload.get("section_id")) + if not section_id: + continue + existing = sections.get(section_id) + if existing is None or _job_sort_key(job) > _job_sort_key(existing): + sections[section_id] = job + return sections + + +def _collect_job_warnings(jobs: list[AgentJobSummary]) -> list[str]: + warnings: list[str] = [] + for job in jobs: + if job.job_type != AgentJobType.SECTION_SCAFFOLD: + continue + heading = _string_value(job.payload.get("heading")) or _string_value( + job.payload.get("artifact_label") + ) + warnings.extend( + _unsupported_claim_warnings( + heading=heading or "section", + raw_claims=job.payload.get("unsupported_claims"), + ) + ) + return warnings + + +def _job_sort_key(job: AgentJobSummary) -> tuple[int, str]: + queued_at = job.queued_at.isoformat() if job.queued_at else "" + return (job.attempt, queued_at) + + +def _section_markdown(heading: str, payload: dict[str, Any]) -> str: + section_markdown = _string_value(payload.get("draft_markdown")).strip() + if not section_markdown: + return f"## {heading}\n\nDraft content is missing for this section." + if section_markdown.lstrip().startswith("#"): + return section_markdown + return f"## {heading}\n\n{section_markdown}" + + +def _visual_placeholders_for_section( + *, + section_id: UUID, + heading: str, + payload: dict[str, Any], +) -> list[str]: + suggested_visuals = payload.get("suggested_visuals") + placeholders: list[str] = [] + if isinstance(suggested_visuals, list): + for index, visual in enumerate(suggested_visuals, start=1): + label = _visual_label(visual, heading=heading, fallback_index=index) + placeholders.append( + f"![{label}](placeholder://{section_id}/{index})" + ) + if placeholders: + return placeholders + return [f"![Visual placeholder: {heading}](placeholder://{section_id}/1)"] + + +def _visual_label(value: Any, *, heading: str, fallback_index: int) -> str: + if isinstance(value, str) and value.strip(): + return value.strip() + if isinstance(value, dict): + for key in ("title", "caption", "prompt", "label"): + key_value = value.get(key) + if isinstance(key_value, str) and key_value.strip(): + return key_value.strip() + return f"{heading} visual {fallback_index}" + + +def _unsupported_claim_warnings(*, heading: str, raw_claims: Any) -> list[str]: + if not isinstance(raw_claims, list): + return [] + warnings: list[str] = [] + for claim in raw_claims: + if isinstance(claim, str) and claim.strip(): + warnings.append(f"{heading}: {claim.strip()}") + continue + if isinstance(claim, dict): + claim_text = claim.get("claim_text") + if isinstance(claim_text, str) and claim_text.strip(): + warnings.append(f"{heading}: {claim_text.strip()}") + return warnings + + +def _build_faq_items( + *, + claims_to_prove: list[str], + keyword: str | None, +) -> list[DraftFaqItem]: + faq_items: list[DraftFaqItem] = [] + for claim in claims_to_prove[:3]: + text = claim.strip() + if not text: + continue + faq_items.append( + DraftFaqItem( + question=f"What evidence supports: {text}?", + answer="Evidence references are listed in the section and summary blocks.", + ) + ) + if faq_items: + return faq_items + base_keyword = (keyword or "this topic").strip() + return [ + DraftFaqItem( + question=f"What should readers know about {base_keyword}?", + answer="The draft sections provide context, implementation details, and references.", + ) + ] + + +def _assemble_body_markdown( + *, + title: str, + meta_title: str, + meta_description: str, + section_blocks: list[str], + evidence_references: list[str], + visual_placeholders: list[str], + faq_items: list[DraftFaqItem], + unsupported_warnings: list[str], +) -> str: + lines = [ + f"# {title}", + "", + f"> Meta title: {meta_title}", + f"> Meta description: {meta_description}", + "", + ] + lines.extend("\n\n".join(section_blocks).splitlines()) + lines.extend(["", "## Evidence References", ""]) + if evidence_references: + for evidence_id in evidence_references: + lines.append(f"- `{evidence_id}`") + else: + lines.append("- No evidence references recorded.") + + lines.extend(["", "## Visual Placeholders", ""]) + for placeholder in visual_placeholders: + lines.append(placeholder) + + lines.extend(["", "## FAQ", ""]) + for item in faq_items: + lines.append(f"### {item.question}") + lines.append("") + lines.append(item.answer) + lines.append("") + + if unsupported_warnings: + lines.extend(["## Unsupported Claim Warnings", ""]) + for warning in unsupported_warnings: + lines.append(f"- {warning}") + lines.append("") + + return "\n".join(lines).strip() + "\n" + + +def _trim_description(value: str) -> str: + normalized = " ".join(value.split()) + if len(normalized) <= 155: + return normalized + return normalized[:152].rstrip() + "..." + + +def _slugify(value: str) -> str: + normalized = value.strip().lower() + normalized = re.sub(r"[^a-z0-9]+", "-", normalized) + normalized = normalized.strip("-") + if normalized: + return normalized + return "draft" + + +def _string_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, str) and item] + + +def _string_value(value: Any) -> str: + if isinstance(value, str): + return value + return "" + + +def _dedupe_strings(values: list[str]) -> list[str]: + deduped: list[str] = [] + seen: set[str] = set() + for value in values: + if value in seen: + continue + seen.add(value) + deduped.append(value) + return deduped + + +def _request_is_empty(request: DraftUpdateRequest) -> bool: + return all( + value is None + for value in ( + request.title, + request.meta_title, + request.meta_description, + request.body_markdown, + request.faq_items, + request.visual_placeholders, + request.evidence_references, + request.unsupported_claim_warnings, + ) + ) + + +def _now() -> datetime: + return datetime.now(UTC) diff --git a/apps/backend/src/domain/contracts/__init__.py b/apps/backend/src/domain/contracts/__init__.py index 08b5938..17e2cfc 100644 --- a/apps/backend/src/domain/contracts/__init__.py +++ b/apps/backend/src/domain/contracts/__init__.py @@ -34,7 +34,12 @@ from .models import ( ClaimSummary, CurrentUser, CurrentUserResponse, + DraftAssembleResponse, + DraftFaqItem, + DraftListResponse, + DraftResponse, DraftSummary, + DraftUpdateRequest, EvidenceSummary, EvidenceCreateRequest, EvidenceMatrixResponse, @@ -102,7 +107,12 @@ __all__ = [ "ClaimSupportStatus", "CurrentUser", "CurrentUserResponse", + "DraftAssembleResponse", + "DraftFaqItem", + "DraftListResponse", + "DraftResponse", "DraftSummary", + "DraftUpdateRequest", "EvidenceSummary", "EvidenceCreateRequest", "EvidenceMatrixResponse", diff --git a/apps/backend/src/domain/contracts/models.py b/apps/backend/src/domain/contracts/models.py index c793e8e..f00658c 100644 --- a/apps/backend/src/domain/contracts/models.py +++ b/apps/backend/src/domain/contracts/models.py @@ -361,6 +361,11 @@ class EvidenceMatrixResponse(ContractModel): insufficient_evidence_reasons: list[str] = Field(default_factory=list) +class DraftFaqItem(ContractModel): + question: str = Field(min_length=1) + answer: str = Field(min_length=1) + + class DraftSummary(ContractModel): id: UUID article_id: UUID @@ -370,7 +375,14 @@ class DraftSummary(ContractModel): meta_title: str | None = None meta_description: str | None = None body_object_key: str | None = None + body_markdown: str = "" + faq_items: list[DraftFaqItem] = Field(default_factory=list) + visual_placeholders: list[str] = Field(default_factory=list) + evidence_references: list[UUID] = Field(default_factory=list) + unsupported_claim_warnings: list[str] = Field(default_factory=list) + based_on_draft_id: UUID | None = None status: ArticleWorkflowStatus + created_at: datetime updated_at: datetime @@ -462,6 +474,29 @@ class AgentJobOutput(ContractModel): payload: JsonObject = Field(default_factory=dict) +class DraftAssembleResponse(ContractModel): + draft: DraftSummary + + +class DraftListResponse(ContractModel): + drafts: list[DraftSummary] = Field(default_factory=list) + + +class DraftResponse(ContractModel): + draft: DraftSummary + + +class DraftUpdateRequest(ContractModel): + title: str | None = Field(default=None, min_length=1) + meta_title: str | None = None + meta_description: str | None = None + body_markdown: str | None = None + faq_items: list[DraftFaqItem] | None = None + visual_placeholders: list[str] | None = None + evidence_references: list[UUID] | None = None + unsupported_claim_warnings: list[str] | None = None + + class ArticleDetailResponse(ContractModel): article: ArticleSummary target_site: TargetSiteConfig | None = None diff --git a/apps/backend/src/domain/contracts/openapi.py b/apps/backend/src/domain/contracts/openapi.py index 639f206..6ab2c78 100644 --- a/apps/backend/src/domain/contracts/openapi.py +++ b/apps/backend/src/domain/contracts/openapi.py @@ -40,7 +40,12 @@ from .models import ( ClaimSummary, CurrentUser, CurrentUserResponse, + DraftAssembleResponse, + DraftFaqItem, + DraftListResponse, + DraftResponse, DraftSummary, + DraftUpdateRequest, EvidenceSummary, EvidenceCreateRequest, EvidenceMatrixResponse, @@ -132,6 +137,11 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = ( ClaimSummary, EvidenceMatrixResponse, DraftSummary, + DraftFaqItem, + DraftAssembleResponse, + DraftListResponse, + DraftResponse, + DraftUpdateRequest, AssetSummary, ReviewSummary, ReviewActionResponse, diff --git a/apps/backend/src/infrastructure/repositories.py b/apps/backend/src/infrastructure/repositories.py index 6450350..191bae0 100644 --- a/apps/backend/src/infrastructure/repositories.py +++ b/apps/backend/src/infrastructure/repositories.py @@ -18,6 +18,8 @@ from src.domain.contracts import ( ClaimRiskLevel, ClaimSupportStatus, ClaimSummary, + DraftFaqItem, + DraftSummary, EvidenceSummary, PlanReviewStatus, PlanSectionSummary, @@ -52,6 +54,7 @@ class BackendRepository: self.articles = ArticlesRepository(self) self.boundary_questions = BoundaryQuestionsRepository(self) self.article_plans = ArticlePlansRepository(self) + self.article_drafts = ArticleDraftsRepository(self) self.research_manifests = ResearchManifestsRepository(self) self.evidence_items = EvidenceItemsRepository(self) self.claims = ClaimsRepository(self) @@ -1014,6 +1017,181 @@ class ArticlePlansRepository: """ +class ArticleDraftsRepository: + def __init__(self, repository: BackendRepository) -> None: + self._repository = repository + + def create_version( + self, + *, + article_id: UUID, + version: int, + title: str, + slug: str, + meta_title: str | None, + meta_description: str | None, + body_object_key: str | None, + body_markdown: str, + faq_items: list[JsonObject], + visual_placeholders: list[str], + evidence_references: list[UUID], + unsupported_claim_warnings: list[str], + based_on_draft_id: UUID | None, + status: ArticleWorkflowStatus, + created_at: datetime, + updated_at: datetime, + ) -> DraftSummary: + draft_id = uuid4() + placeholder = self._repository.placeholder() + json_cast = self._repository.json_cast() + with self._repository.connection() as connection: + connection.execute( + f""" + INSERT INTO article_drafts ( + id, + article_id, + version, + title, + slug, + meta_title, + meta_description, + body_object_key, + body_markdown, + faq_items, + visual_placeholders, + evidence_references, + unsupported_claim_warnings, + based_on_draft_id, + status, + created_at, + updated_at + ) + VALUES ( + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}{json_cast}, + {placeholder}{json_cast}, + {placeholder}{json_cast}, + {placeholder}{json_cast}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder} + ) + """, + ( + str(draft_id), + str(article_id), + version, + title, + slug, + meta_title, + meta_description, + body_object_key, + body_markdown, + _json_value(faq_items), + _json_value(visual_placeholders), + _json_value([str(item_id) for item_id in evidence_references]), + _json_value(unsupported_claim_warnings), + _uuid_value(based_on_draft_id), + status.value, + _datetime_value(created_at), + _datetime_value(updated_at), + ), + ) + + return self.get(article_id=article_id, draft_id=draft_id) + + def list_for_article(self, article_id: UUID) -> list[DraftSummary]: + placeholder = self._repository.placeholder() + with self._repository.connection() as connection: + rows = connection.execute( + f""" + SELECT {self._select_columns()} + FROM article_drafts + WHERE article_id = {placeholder} + ORDER BY version DESC + """, + (str(article_id),), + ).fetchall() + + return [_draft_summary_from_row(row) for row in rows] + + def get(self, *, article_id: UUID, draft_id: UUID) -> DraftSummary: + placeholder = self._repository.placeholder() + with self._repository.connection() as connection: + row = connection.execute( + f""" + SELECT {self._select_columns()} + FROM article_drafts + WHERE article_id = {placeholder} AND id = {placeholder} + """, + (str(article_id), str(draft_id)), + ).fetchone() + + if row is None: + raise LookupError(f"Draft not found: {draft_id}") + return _draft_summary_from_row(row) + + def latest_for_article(self, article_id: UUID) -> DraftSummary | None: + placeholder = self._repository.placeholder() + with self._repository.connection() as connection: + row = connection.execute( + f""" + SELECT {self._select_columns()} + FROM article_drafts + WHERE article_id = {placeholder} + ORDER BY version DESC + LIMIT 1 + """, + (str(article_id),), + ).fetchone() + if row is None: + return None + return _draft_summary_from_row(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_drafts + WHERE article_id = {placeholder} + """, + (str(article_id),), + ).fetchone() + return int(_row_value(row, "version")) + + def _select_columns(self) -> str: + return """ + id, + article_id, + version, + title, + slug, + meta_title, + meta_description, + body_object_key, + body_markdown, + faq_items, + visual_placeholders, + evidence_references, + unsupported_claim_warnings, + based_on_draft_id, + status, + created_at, + updated_at + """ + + class ResearchManifestsRepository: def __init__(self, repository: BackendRepository) -> None: self._repository = repository @@ -2176,6 +2354,35 @@ def _claim_from_row(row: Any) -> ClaimSummary: ) +def _draft_summary_from_row(row: Any) -> DraftSummary: + return DraftSummary( + id=_row_value(row, "id"), + article_id=_row_value(row, "article_id"), + version=_row_value(row, "version"), + title=_row_value(row, "title"), + slug=_row_value(row, "slug"), + meta_title=_row_value(row, "meta_title"), + meta_description=_row_value(row, "meta_description"), + body_object_key=_row_value(row, "body_object_key"), + body_markdown=_row_value(row, "body_markdown"), + faq_items=[ + DraftFaqItem.model_validate(item) + for item in _json_from_row(row, "faq_items") + ], + visual_placeholders=_json_from_row(row, "visual_placeholders"), + evidence_references=[ + UUID(value) for value in _json_from_row(row, "evidence_references") + ], + unsupported_claim_warnings=_json_from_row( + row, "unsupported_claim_warnings" + ), + based_on_draft_id=_row_value(row, "based_on_draft_id"), + status=_row_value(row, "status"), + 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"), diff --git a/apps/backend/src/infrastructure/schema.py b/apps/backend/src/infrastructure/schema.py index ac97cf8..4acf2a6 100644 --- a/apps/backend/src/infrastructure/schema.py +++ b/apps/backend/src/infrastructure/schema.py @@ -215,12 +215,24 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = ( meta_title TEXT, meta_description TEXT, body_object_key TEXT, + body_markdown TEXT NOT NULL DEFAULT '', + faq_items JSONB NOT NULL DEFAULT '[]'::jsonb, + visual_placeholders JSONB NOT NULL DEFAULT '[]'::jsonb, + evidence_references JSONB NOT NULL DEFAULT '[]'::jsonb, + unsupported_claim_warnings JSONB NOT NULL DEFAULT '[]'::jsonb, + based_on_draft_id UUID REFERENCES article_drafts(id) ON DELETE SET NULL, status TEXT NOT NULL CHECK (status IN ({ARTICLE_STATUS_VALUES})), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (article_id, version) ) """, + "ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS body_markdown TEXT NOT NULL DEFAULT ''", + "ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS faq_items JSONB NOT NULL DEFAULT '[]'::jsonb", + "ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS visual_placeholders JSONB NOT NULL DEFAULT '[]'::jsonb", + "ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS evidence_references JSONB NOT NULL DEFAULT '[]'::jsonb", + "ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS unsupported_claim_warnings JSONB NOT NULL DEFAULT '[]'::jsonb", + "ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS based_on_draft_id UUID REFERENCES article_drafts(id) ON DELETE SET NULL", f""" CREATE TABLE IF NOT EXISTS assets ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -525,6 +537,12 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = ( meta_title TEXT, meta_description TEXT, body_object_key TEXT, + body_markdown TEXT NOT NULL DEFAULT '', + faq_items TEXT NOT NULL DEFAULT '[]', + visual_placeholders TEXT NOT NULL DEFAULT '[]', + evidence_references TEXT NOT NULL DEFAULT '[]', + unsupported_claim_warnings TEXT NOT NULL DEFAULT '[]', + based_on_draft_id TEXT, status TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/apps/backend/src/presentation/main.py b/apps/backend/src/presentation/main.py index 5bccad8..d2375a0 100644 --- a/apps/backend/src/presentation/main.py +++ b/apps/backend/src/presentation/main.py @@ -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.auth import router as auth_router from src.presentation.routes.boundary_questions import router as boundary_questions_router +from src.presentation.routes.drafts import router as drafts_router from src.presentation.routes.evidence import router as evidence_router from src.presentation.routes.plans import router as plans_router from src.presentation.routes.sites import router as sites_router @@ -23,6 +24,7 @@ app.include_router(articles_router) app.include_router(boundary_questions_router) app.include_router(plans_router) app.include_router(evidence_router) +app.include_router(drafts_router) app.include_router(agent_jobs_router) app.include_router(internal_agent_jobs_router) app.include_router(sites_router) diff --git a/apps/backend/src/presentation/routes/drafts.py b/apps/backend/src/presentation/routes/drafts.py new file mode 100644 index 0000000..955e210 --- /dev/null +++ b/apps/backend/src/presentation/routes/drafts.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status + +from src.application.drafts import assemble_draft, get_draft, list_drafts, update_draft +from src.domain.auth import EDITOR_OR_ADMIN_ROLES +from src.domain.contracts import ( + CurrentUser, + DraftAssembleResponse, + DraftListResponse, + DraftResponse, + DraftUpdateRequest, +) +from src.infrastructure.repositories import BackendRepository +from src.presentation.dependencies import get_repository, require_roles + + +router = APIRouter(prefix="/api", tags=["drafts"]) + + +@router.post( + "/articles/{article_id}/draft/assemble", + response_model=DraftAssembleResponse, + status_code=status.HTTP_201_CREATED, +) +def post_assemble_draft( + article_id: UUID, + _: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> DraftAssembleResponse: + try: + return assemble_draft(repository, article_id=article_id) + except LookupError as error: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error + except (PermissionError, ValueError) as error: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error + + +@router.get( + "/articles/{article_id}/drafts", + response_model=DraftListResponse, +) +def get_article_drafts( + article_id: UUID, + _: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> DraftListResponse: + try: + return list_drafts(repository, article_id=article_id) + except LookupError as error: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error + + +@router.get( + "/articles/{article_id}/drafts/{draft_id}", + response_model=DraftResponse, +) +def get_article_draft( + article_id: UUID, + draft_id: UUID, + _: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> DraftResponse: + try: + return get_draft(repository, article_id=article_id, draft_id=draft_id) + except LookupError as error: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error + + +@router.patch( + "/articles/{article_id}/drafts/{draft_id}", + response_model=DraftResponse, +) +def patch_article_draft( + article_id: UUID, + draft_id: UUID, + request: DraftUpdateRequest, + _: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> DraftResponse: + try: + return update_draft( + repository, + article_id=article_id, + draft_id=draft_id, + request=request, + ) + except LookupError as error: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error + except ValueError as error: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error diff --git a/apps/backend/tests/integration/test_draft_assembly_public_api.py b/apps/backend/tests/integration/test_draft_assembly_public_api.py new file mode 100644 index 0000000..70643da --- /dev/null +++ b/apps/backend/tests/integration/test_draft_assembly_public_api.py @@ -0,0 +1,395 @@ +from __future__ import annotations + +import os +import sys +import tempfile +import unittest +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from fastapi.testclient import TestClient + + +BACKEND_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(BACKEND_ROOT)) + +from src.application.seed_data import seed_reference_data # noqa: E402 +from src.infrastructure.repositories import open_backend_repository # noqa: E402 +from src.presentation.dependencies import get_repository # noqa: E402 +from src.presentation.main import app # noqa: E402 + + +DEMO_EDITOR_EMAIL = "editor@example.com" +DEMO_ADMIN_EMAIL = "admin@example.com" +DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email" + + +class DraftAssemblyPublicApiTest(unittest.TestCase): + def setUp(self) -> None: + self.tmp_dir = tempfile.TemporaryDirectory() + os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects") + dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'draft-assembly.db'}" + self.repository = open_backend_repository(dsn) + self.repository.setup() + seed_reference_data(self.repository) + app.dependency_overrides[get_repository] = lambda: self.repository + self.client = TestClient(app) + + def tearDown(self) -> None: + app.dependency_overrides.clear() + os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None) + self.tmp_dir.cleanup() + + def test_assemble_draft_includes_all_sections_in_order_and_full_payload(self) -> None: + article_id, section_headings = self._prepare_article_for_draft_assembly() + section_jobs = self._start_parallel_production(article_id) + + for index, job in enumerate(section_jobs, start=1): + if index == 1: + draft_markdown = ( + f"## {job['payload']['heading']}\n\n" + "Use [official documentation](https://example.com/docs) for setup.\n\n" + "| Step | Result |\n" + "| --- | --- |\n" + "| 1 | Ready |\n\n" + "![Existing mock](placeholder://existing/mock)\n" + ) + else: + draft_markdown = ( + f"## {job['payload']['heading']}\n\n" + f"Body for section {index}." + ) + completed = self._complete_job( + job["id"], + output={ + "status": "SUCCEEDED", + "output_files": [{"path": f"outputs/section-{index}.md"}], + "payload": { + "used_evidence_ids": job["payload"]["used_evidence_ids"], + "unsupported_claims": [], + "suggested_visuals": [{"title": f"Visual {index}"}], + "draft_markdown": draft_markdown, + }, + }, + ) + self.assertEqual("SUCCEEDED", completed["status"]) + + assemble_response = self.client.post( + f"/api/articles/{article_id}/draft/assemble", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(201, assemble_response.status_code, assemble_response.text) + + draft = assemble_response.json()["draft"] + self.assertEqual(1, draft["version"]) + self.assertTrue(draft["meta_title"]) + self.assertTrue(draft["meta_description"]) + self.assertGreaterEqual(len(draft["faq_items"]), 1) + self.assertGreaterEqual(len(draft["visual_placeholders"]), 1) + self.assertGreaterEqual(len(draft["evidence_references"]), 1) + self.assertIn("unsupported_claim_warnings", draft) + + body_markdown = draft["body_markdown"] + cursor = -1 + for heading in section_headings: + position = body_markdown.find(f"## {heading}") + self.assertGreater(position, cursor) + cursor = position + + self.assertIn("## Evidence References", body_markdown) + self.assertIn("## Visual Placeholders", body_markdown) + self.assertIn("## FAQ", body_markdown) + self.assertIn("| Step | Result |", body_markdown) + self.assertIn("[official documentation](https://example.com/docs)", body_markdown) + self.assertIn("![Existing mock](placeholder://existing/mock)", body_markdown) + + drafts_response = self.client.get( + f"/api/articles/{article_id}/drafts", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(200, drafts_response.status_code, drafts_response.text) + self.assertEqual(1, len(drafts_response.json()["drafts"])) + + draft_id = draft["id"] + draft_detail_response = self.client.get( + f"/api/articles/{article_id}/drafts/{draft_id}", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(200, draft_detail_response.status_code, draft_detail_response.text) + self.assertEqual(draft["body_markdown"], draft_detail_response.json()["draft"]["body_markdown"]) + + def test_patch_creates_new_immutable_draft_version(self) -> None: + article_id, _ = self._prepare_article_for_draft_assembly() + section_jobs = self._start_parallel_production(article_id) + self._complete_all_sections_success(section_jobs) + first_draft = self._assemble(article_id) + + patch_response = self.client.patch( + f"/api/articles/{article_id}/drafts/{first_draft['id']}", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json={ + "meta_title": "Updated metadata title", + "body_markdown": first_draft["body_markdown"] + "\n\nRevision marker.\n", + }, + ) + self.assertEqual(200, patch_response.status_code, patch_response.text) + second_draft = patch_response.json()["draft"] + self.assertEqual(2, second_draft["version"]) + self.assertEqual(first_draft["id"], second_draft["based_on_draft_id"]) + self.assertEqual("Updated metadata title", second_draft["meta_title"]) + self.assertIn("Revision marker.", second_draft["body_markdown"]) + + first_draft_refresh = self.client.get( + f"/api/articles/{article_id}/drafts/{first_draft['id']}", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ).json()["draft"] + self.assertEqual(1, first_draft_refresh["version"]) + self.assertNotIn("Revision marker.", first_draft_refresh["body_markdown"]) + self.assertNotEqual(second_draft["meta_title"], first_draft_refresh["meta_title"]) + + list_response = self.client.get( + f"/api/articles/{article_id}/drafts", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(200, list_response.status_code, list_response.text) + versions = [item["version"] for item in list_response.json()["drafts"]] + self.assertEqual([2, 1], versions) + + def test_assemble_fails_with_clear_conflict_when_scaffold_missing(self) -> None: + article_id, section_headings = self._prepare_article_for_draft_assembly() + section_jobs = self._start_parallel_production(article_id) + self.assertGreaterEqual(len(section_jobs), 2) + + for job in section_jobs[:-1]: + completed = self._complete_job( + job["id"], + output={ + "status": "SUCCEEDED", + "output_files": [{"path": "outputs/ready.md"}], + "payload": { + "used_evidence_ids": job["payload"]["used_evidence_ids"], + "unsupported_claims": [], + "draft_markdown": f"## {job['payload']['heading']}\n\nReady.", + }, + }, + ) + self.assertEqual("SUCCEEDED", completed["status"]) + + missing_heading = section_headings[-1] + assemble_response = self.client.post( + f"/api/articles/{article_id}/draft/assemble", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(409, assemble_response.status_code, assemble_response.text) + self.assertIn("Missing successful section scaffolds", assemble_response.text) + self.assertIn(missing_heading, assemble_response.text) + + def test_assemble_carries_unsupported_claim_warnings_from_scaffold_attempts(self) -> None: + article_id, _ = self._prepare_article_for_draft_assembly() + section_jobs = self._start_parallel_production(article_id) + self.assertGreaterEqual(len(section_jobs), 1) + + first_job = section_jobs[0] + failed = self._complete_job( + first_job["id"], + output={ + "status": "SUCCEEDED", + "output_files": [{"path": "outputs/failed.md"}], + "payload": { + "used_evidence_ids": first_job["payload"]["used_evidence_ids"], + "unsupported_claims": [ + {"claim_text": "Unverified migration metric", "risk_level": "high"} + ], + "draft_markdown": "Content with unsupported claim", + }, + }, + ) + self.assertEqual("FAILED", failed["status"]) + + retry_response = self.client.post( + f"/api/agent-jobs/{first_job['id']}/retry", + headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}, + ) + self.assertEqual(201, retry_response.status_code, retry_response.text) + retry_job = retry_response.json()["job"] + retry_completed = self._complete_job( + retry_job["id"], + output={ + "status": "SUCCEEDED", + "output_files": [{"path": "outputs/retry.md"}], + "payload": { + "used_evidence_ids": retry_job["payload"]["used_evidence_ids"], + "unsupported_claims": [], + "draft_markdown": f"## {retry_job['payload']['heading']}\n\nClean section.", + }, + }, + ) + self.assertEqual("SUCCEEDED", retry_completed["status"]) + + for job in section_jobs[1:]: + self._complete_job( + job["id"], + output={ + "status": "SUCCEEDED", + "output_files": [{"path": "outputs/ok.md"}], + "payload": { + "used_evidence_ids": job["payload"]["used_evidence_ids"], + "unsupported_claims": [], + "draft_markdown": f"## {job['payload']['heading']}\n\nReady.", + }, + }, + ) + + draft = self._assemble(article_id) + warnings = draft["unsupported_claim_warnings"] + self.assertTrue(any("Unverified migration metric" in warning for warning in warnings)) + self.assertIn("## Unsupported Claim Warnings", draft["body_markdown"]) + + def test_draft_endpoints_return_404_for_missing_resources(self) -> None: + random_article_id = uuid4() + list_response = self.client.get( + f"/api/articles/{random_article_id}/drafts", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(404, list_response.status_code, list_response.text) + + article_id, _ = self._prepare_article_for_draft_assembly() + random_draft_id = uuid4() + detail_response = self.client.get( + f"/api/articles/{article_id}/drafts/{random_draft_id}", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(404, detail_response.status_code, detail_response.text) + + patch_response = self.client.patch( + f"/api/articles/{article_id}/drafts/{random_draft_id}", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json={"title": "Will fail"}, + ) + self.assertEqual(404, patch_response.status_code, patch_response.text) + + def _assemble(self, article_id: str) -> dict[str, Any]: + response = self.client.post( + f"/api/articles/{article_id}/draft/assemble", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(201, response.status_code, response.text) + return response.json()["draft"] + + def _complete_all_sections_success(self, section_jobs: list[dict[str, Any]]) -> None: + for index, job in enumerate(section_jobs, start=1): + completed = self._complete_job( + job["id"], + output={ + "status": "SUCCEEDED", + "output_files": [{"path": f"outputs/{index}.md"}], + "payload": { + "used_evidence_ids": job["payload"]["used_evidence_ids"], + "unsupported_claims": [], + "draft_markdown": f"## {job['payload']['heading']}\n\nSection {index}.", + }, + }, + ) + self.assertEqual("SUCCEEDED", completed["status"]) + + def _complete_job(self, job_id: str, *, output: dict[str, Any]) -> dict[str, Any]: + response = self.client.post( + f"/internal/agent-jobs/{job_id}/complete", + json={ + "workspace_path": f"/tmp/{job_id}", + "stdout": "fake section scaffolding runner\n", + "stderr": "", + "exit_code": 0, + "duration_ms": 1, + "output": output, + }, + ) + self.assertEqual(200, response.status_code, response.text) + return response.json()["job"] + + def _start_parallel_production(self, article_id: str) -> list[dict[str, Any]]: + start_response = self.client.post( + f"/api/articles/{article_id}/draft/start", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(202, start_response.status_code, start_response.text) + jobs = start_response.json()["jobs"] + return [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"] + + def _prepare_article_for_draft_assembly(self) -> tuple[str, list[str]]: + article_id, section_headings = self._create_article_with_approved_plan() + self._ensure_evidence_ready(article_id) + return article_id, section_headings + + def _ensure_evidence_ready(self, article_id: str) -> None: + research_response = self.client.post( + f"/api/articles/{article_id}/research/start", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(201, research_response.status_code, research_response.text) + + evidence_response = self.client.get( + f"/api/articles/{article_id}/evidence", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(200, evidence_response.status_code, evidence_response.text) + self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"]) + self.assertFalse(evidence_response.json()["insufficient_evidence_reasons"]) + + def _create_article_with_approved_plan(self) -> tuple[str, list[str]]: + site = self.client.get( + "/api/sites", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ).json()[0]["site"] + article = self.client.post( + "/api/articles", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json={ + "target_site_id": site["id"], + "brief_description": "Assemble draft from section scaffolds with versions.", + "working_title": "Draft Assembly With Versions", + "content_type": "longform_guide", + "primary_keyword": "draft assembly editor preview", + }, + ).json()["article"] + article_id = article["id"] + + questions = self.client.post( + f"/api/articles/{article_id}/boundary-questions/generate", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ).json()["questions"] + for question in questions: + if question["is_required"]: + patch_response = self.client.patch( + f"/api/articles/{article_id}/boundary-questions/{question['id']}", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json={"answer": f"Answer for {question['category']}"}, + ) + self.assertEqual(200, patch_response.status_code, patch_response.text) + + submit_response = self.client.post( + f"/api/articles/{article_id}/boundary-questions/submit", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(200, submit_response.status_code, submit_response.text) + + plan_response = self.client.post( + f"/api/articles/{article_id}/plan/generate", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(201, plan_response.status_code, plan_response.text) + plan = plan_response.json()["plan"] + self.assertGreaterEqual(len(plan["sections"]), 1) + + approve_response = self.client.post( + f"/api/articles/{article_id}/plans/{plan['id']}/approve", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(200, approve_response.status_code, approve_response.text) + + return article_id, [section["heading"] for section in plan["sections"]] + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/frontend/src/app/globals.css b/apps/frontend/src/app/globals.css index 7664f05..5d14cbc 100644 --- a/apps/frontend/src/app/globals.css +++ b/apps/frontend/src/app/globals.css @@ -344,6 +344,44 @@ main { padding: 10px 0; } +.draftEditor { + margin-top: 28px; +} + +.draftEditor .detailGrid select { + width: 100%; + border-radius: 6px; + border: 1px solid #d0d5dd; + padding: 10px; + font: inherit; +} + +.previewPane { + border: 1px solid #d0d5dd; + border-radius: 8px; + background: #ffffff; + padding: 14px; +} + +.previewImage { + margin: 0; + border: 1px dashed #d0d5dd; + border-radius: 8px; + padding: 10px; +} + +.previewImage img { + display: block; + max-width: 100%; + max-height: 180px; +} + +.previewImage figcaption { + margin-top: 6px; + color: #667085; + font-size: 12px; +} + @media (max-width: 640px) { main { padding: 28px; diff --git a/apps/frontend/src/features/article-draft/model.ts b/apps/frontend/src/features/article-draft/model.ts new file mode 100644 index 0000000..f3d3c4e --- /dev/null +++ b/apps/frontend/src/features/article-draft/model.ts @@ -0,0 +1,170 @@ +import type { DraftFaqItem, DraftSummary } from "@pipeline/shared"; + +export type DraftPreviewBlock = + | { type: "heading"; level: number; text: string } + | { type: "paragraph"; text: string; links: DraftPreviewLink[] } + | { type: "table"; headers: string[]; rows: string[][] } + | { type: "image"; alt: string; src: string } + | { type: "faq"; items: DraftFaqItem[] }; + +export type DraftPreviewLink = { + label: string; + href: string; +}; + +export type DraftVersionComparison = { + leftVersion: number; + rightVersion: number; + titleChanged: boolean; + metaTitleChanged: boolean; + metaDescriptionChanged: boolean; + bodyChanged: boolean; + changedLineCount: number; +}; + +export function selectDraftById( + drafts: readonly DraftSummary[], + draftId: string | null, +): DraftSummary | null { + if (!draftId) { + return drafts[0] ?? null; + } + return drafts.find((draft) => draft.id === draftId) ?? null; +} + +export function compareDraftVersions( + left: DraftSummary, + right: DraftSummary, +): DraftVersionComparison { + const leftBodyMarkdown = left.body_markdown ?? ""; + const rightBodyMarkdown = right.body_markdown ?? ""; + const leftLines = normalizedLines(leftBodyMarkdown); + const rightLines = normalizedLines(rightBodyMarkdown); + return { + leftVersion: left.version, + rightVersion: right.version, + titleChanged: left.title !== right.title, + metaTitleChanged: (left.meta_title ?? "") !== (right.meta_title ?? ""), + metaDescriptionChanged: + (left.meta_description ?? "") !== (right.meta_description ?? ""), + bodyChanged: leftBodyMarkdown !== rightBodyMarkdown, + changedLineCount: changedLineCount(leftLines, rightLines), + }; +} + +export function parseDraftPreview( + markdown: string, + faqItems: readonly DraftFaqItem[], +): DraftPreviewBlock[] { + const lines = markdown.split(/\r?\n/); + const blocks: DraftPreviewBlock[] = []; + let index = 0; + + while (index < lines.length) { + const line = lines[index].trim(); + if (!line) { + index += 1; + continue; + } + + if (line.startsWith("#")) { + const match = line.match(/^(#{1,6})\s+(.+)$/); + if (match) { + blocks.push({ + type: "heading", + level: match[1].length, + text: match[2].trim(), + }); + index += 1; + continue; + } + } + + if (line.startsWith("![") && line.includes("](") && line.endsWith(")")) { + const match = line.match(/^!\[([^\]]*)\]\(([^)]+)\)$/); + if (match) { + blocks.push({ + type: "image", + alt: match[1], + src: match[2], + }); + index += 1; + continue; + } + } + + if (line.startsWith("|") && index + 1 < lines.length && lines[index + 1].includes("|")) { + const tableLines: string[] = []; + while (index < lines.length && lines[index].trim().startsWith("|")) { + tableLines.push(lines[index].trim()); + index += 1; + } + if (tableLines.length >= 2) { + const headers = parseTableRow(tableLines[0]); + const rows = tableLines.slice(2).map(parseTableRow); + blocks.push({ + type: "table", + headers, + rows, + }); + } + continue; + } + + const paragraphLines: string[] = []; + while (index < lines.length && lines[index].trim()) { + paragraphLines.push(lines[index].trim()); + index += 1; + } + const paragraphText = paragraphLines.join(" "); + blocks.push({ + type: "paragraph", + text: paragraphText, + links: extractLinks(paragraphText), + }); + } + + if (faqItems.length > 0) { + blocks.push({ + type: "faq", + items: [...faqItems], + }); + } + return blocks; +} + +function normalizedLines(value: string): string[] { + return value + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +function changedLineCount(left: string[], right: string[]): number { + const limit = Math.max(left.length, right.length); + let changes = 0; + for (let index = 0; index < limit; index += 1) { + if ((left[index] ?? "") !== (right[index] ?? "")) { + changes += 1; + } + } + return changes; +} + +function parseTableRow(row: string): string[] { + return row + .slice(1, row.endsWith("|") ? -1 : row.length) + .split("|") + .map((cell) => cell.trim()); +} + +function extractLinks(text: string): DraftPreviewLink[] { + const links: DraftPreviewLink[] = []; + const pattern = /\[([^\]]+)\]\(([^)]+)\)/g; + let match: RegExpExecArray | null = pattern.exec(text); + while (match) { + links.push({ label: match[1], href: match[2] }); + match = pattern.exec(text); + } + return links; +} diff --git a/apps/frontend/src/features/article-draft/ui.tsx b/apps/frontend/src/features/article-draft/ui.tsx new file mode 100644 index 0000000..73493e0 --- /dev/null +++ b/apps/frontend/src/features/article-draft/ui.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import type { DraftSummary } from "@pipeline/shared"; + +import { ApiError, assembleDraft, updateDraft } from "@/shared/pipeline-api"; +import { + compareDraftVersions, + parseDraftPreview, + selectDraftById, +} from "./model"; + +type DraftEditorPanelProps = { + articleId: string; + initialDrafts: readonly DraftSummary[]; +}; + +type DraftEditorState = { + title: string; + metaTitle: string; + metaDescription: string; + bodyMarkdown: string; +}; + +export function DraftEditorPanel({ articleId, initialDrafts }: DraftEditorPanelProps) { + const [drafts, setDrafts] = useState(() => [...initialDrafts]); + const [selectedDraftId, setSelectedDraftId] = useState( + initialDrafts[0]?.id ?? null, + ); + const [compareDraftId, setCompareDraftId] = useState(null); + const [message, setMessage] = useState(""); + const [isBusy, setIsBusy] = useState(false); + const [editor, setEditor] = useState(() => + createEditorState(initialDrafts[0] ?? null), + ); + + const selectedDraft = useMemo( + () => selectDraftById(drafts, selectedDraftId), + [drafts, selectedDraftId], + ); + const compareDraft = useMemo( + () => selectDraftById(drafts, compareDraftId), + [drafts, compareDraftId], + ); + const previewBlocks = useMemo( + () => parseDraftPreview(editor.bodyMarkdown, selectedDraft?.faq_items ?? []), + [editor.bodyMarkdown, selectedDraft], + ); + const comparison = useMemo(() => { + if (!selectedDraft || !compareDraft || selectedDraft.id === compareDraft.id) { + return null; + } + return compareDraftVersions(selectedDraft, compareDraft); + }, [selectedDraft, compareDraft]); + + function onSelectDraft(draftId: string) { + setSelectedDraftId(draftId); + setCompareDraftId((current) => (current === draftId ? null : current)); + const nextDraft = selectDraftById(drafts, draftId); + setEditor(createEditorState(nextDraft)); + } + + async function onAssembleDraft() { + setIsBusy(true); + setMessage(""); + try { + const response = await assembleDraft(articleId); + setDrafts((current) => [response.draft, ...current]); + setSelectedDraftId(response.draft.id); + setEditor(createEditorState(response.draft)); + setMessage(`Draft v${response.draft.version} assembled.`); + } catch (error) { + setMessage(error instanceof ApiError ? error.message : "Failed to assemble draft."); + } finally { + setIsBusy(false); + } + } + + async function onSaveDraft() { + if (!selectedDraft) { + setMessage("Assemble draft before editing."); + return; + } + + setIsBusy(true); + setMessage(""); + try { + const response = await updateDraft(articleId, selectedDraft.id, { + title: editor.title, + meta_title: editor.metaTitle, + meta_description: editor.metaDescription, + body_markdown: editor.bodyMarkdown, + }); + setDrafts((current) => [response.draft, ...current]); + setSelectedDraftId(response.draft.id); + setEditor(createEditorState(response.draft)); + setMessage(`Saved draft v${response.draft.version}.`); + } catch (error) { + setMessage(error instanceof ApiError ? error.message : "Failed to save draft."); + } finally { + setIsBusy(false); + } + } + + return ( +
+
+
+

Draft editor

+

{drafts.length ? `${drafts.length} version(s)` : "No drafts yet"}

+
+ +
+ +
+
+
Selected draft
+
+ +
+
+
+
Compare with
+
+ +
+
+
+ + {comparison ? ( +

+ Compare v{comparison.leftVersion} vs v{comparison.rightVersion}: changed lines{" "} + {comparison.changedLineCount}, title {comparison.titleChanged ? "changed" : "same"}, + meta title {comparison.metaTitleChanged ? "changed" : "same"}, description{" "} + {comparison.metaDescriptionChanged ? "changed" : "same"}. +

+ ) : null} + + {message ?

{message}

: null} + +
{ + event.preventDefault(); + void onSaveDraft(); + }} + > + + + setEditor((current) => ({ ...current, title: event.target.value })) + } + /> + + + setEditor((current) => ({ ...current, metaTitle: event.target.value })) + } + /> + + + setEditor((current) => ({ ...current, metaDescription: event.target.value })) + } + /> + +