From 8243f20b0ecda65dc7e0db09fa9b7d8d3204097b Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 21 May 2026 23:55:15 +0300 Subject: [PATCH] Implement task 014 assets and media library workflow --- apps/backend/src/application/articles.py | 2 + apps/backend/src/application/assets.py | 361 ++++++++++ apps/backend/src/application/drafts.py | 34 + apps/backend/src/domain/contracts/__init__.py | 14 + apps/backend/src/domain/contracts/enums.py | 3 + apps/backend/src/domain/contracts/models.py | 47 ++ apps/backend/src/domain/contracts/openapi.py | 14 + apps/backend/src/domain/schema.py | 2 + .../src/infrastructure/object_storage.py | 39 +- .../src/infrastructure/repositories.py | 360 ++++++++++ apps/backend/src/infrastructure/schema.py | 38 ++ apps/backend/src/presentation/main.py | 2 + .../backend/src/presentation/routes/assets.py | 149 +++++ .../test_assets_media_library_public_api.py | 366 ++++++++++ .../src/features/article-assets/model.ts | 71 ++ .../src/features/article-assets/ui.tsx | 57 ++ .../src/features/article-draft/ui.tsx | 13 +- .../src/pages/article-detail/index.tsx | 8 +- apps/frontend/src/shared/pipeline-api.ts | 64 ++ .../tests/article_assets.model.test.mjs | 70 ++ packages/shared/openapi.json | 624 +++++++++++++++++- packages/shared/src/api-types.ts | 49 +- tasks/014-assets-and-media-library.md | 58 +- 23 files changed, 2423 insertions(+), 22 deletions(-) create mode 100644 apps/backend/src/application/assets.py create mode 100644 apps/backend/src/presentation/routes/assets.py create mode 100644 apps/backend/tests/integration/test_assets_media_library_public_api.py create mode 100644 apps/frontend/src/features/article-assets/model.ts create mode 100644 apps/frontend/src/features/article-assets/ui.tsx create mode 100644 apps/frontend/tests/article_assets.model.test.mjs diff --git a/apps/backend/src/application/articles.py b/apps/backend/src/application/articles.py index 7b5adea..9b405b6 100644 --- a/apps/backend/src/application/articles.py +++ b/apps/backend/src/application/articles.py @@ -64,6 +64,7 @@ def get_article_detail( 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) + assets = repository.assets.list_for_article(article_id) agent_jobs = repository.agent_jobs.list_for_article(article_id) return ArticleDetailResponse( article=article, @@ -74,6 +75,7 @@ def get_article_detail( draft=draft, evidence=evidence, claims=claims, + assets=assets, agent_jobs=agent_jobs, research_manifests=research_manifests, ) diff --git a/apps/backend/src/application/assets.py b/apps/backend/src/application/assets.py new file mode 100644 index 0000000..6d0e2ad --- /dev/null +++ b/apps/backend/src/application/assets.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path +from uuid import UUID + +from src.domain.contracts import ( + AssetGenerateSpecsResponse, + AssetListResponse, + AssetResponse, + AssetStatus, + AssetSummary, + AssetType, + AssetUpdateRequest, + AssetUploadResponse, + CurrentUser, + PlanReviewStatus, +) +from src.infrastructure.object_storage import ObjectStorageClient + + +def generate_asset_specs( + repository: object, + *, + article_id: UUID, + current_user: CurrentUser, +) -> AssetGenerateSpecsResponse: + repository.articles.get(article_id) + plan = _approved_plan(repository, article_id=article_id) + if plan is None or not plan.sections: + raise PermissionError("Approved plan with sections is required before asset specs") + + now = _now() + created_assets: list[AssetSummary] = [] + for index, section in enumerate(plan.sections, start=1): + asset_type = _asset_type_for_section(index) + title = f"{section.heading} ({asset_type.value})" + asset = repository.assets.create( + article_id=article_id, + section_id=section.id, + asset_type=asset_type, + title=title, + prompt=( + "Create an editorial-ready visual for section " + f"'{section.heading}' with evidence-backed details." + ), + object_key=None, + file_url=None, + alt_text=f"Visual for {section.heading}", + caption=f"Supporting visual for section: {section.heading}", + status=AssetStatus.GENERATED, + created_at=now, + updated_at=now, + ) + revision_index = repository.assets.next_revision_index( + article_id=article_id, + asset_id=asset.id, + ) + repository.assets.create_revision( + article_id=article_id, + asset_id=asset.id, + revision_index=revision_index, + action="SPEC_GENERATED", + actor_user_id=current_user.id, + payload={ + "section_id": str(section.id), + "asset_type": asset.asset_type.value, + "title": asset.title, + }, + created_at=now, + ) + created_assets.append( + repository.assets.get(article_id=article_id, asset_id=asset.id) + ) + + repository.articles.create_workflow_event( + article_id=article_id, + event_type="ASSET_SPECS_GENERATED", + from_status=None, + to_status=None, + actor_user_id=current_user.id, + payload={ + "count": len(created_assets), + "asset_ids": [str(asset.id) for asset in created_assets], + }, + created_at=now, + ) + return AssetGenerateSpecsResponse(assets=created_assets) + + +def list_assets(repository: object, *, article_id: UUID) -> AssetListResponse: + repository.articles.get(article_id) + return AssetListResponse(assets=repository.assets.list_for_article(article_id)) + + +def update_asset( + repository: object, + *, + article_id: UUID, + asset_id: UUID, + request: AssetUpdateRequest, + current_user: CurrentUser, +) -> AssetResponse: + current = repository.assets.get(article_id=article_id, asset_id=asset_id) + next_section_id = request.section_id if request.section_id is not None else current.section_id + next_title = request.title if request.title is not None else current.title + next_prompt = request.prompt if request.prompt is not None else current.prompt + next_alt_text = request.alt_text if request.alt_text is not None else current.alt_text + next_caption = request.caption if request.caption is not None else current.caption + next_status = request.status if request.status is not None else current.status + changed_fields = _changed_fields( + current=current, + section_id=next_section_id, + title=next_title, + prompt=next_prompt, + alt_text=next_alt_text, + caption=next_caption, + status=next_status, + ) + if not changed_fields: + return AssetResponse(asset=current) + + now = _now() + repository.assets.update( + article_id=article_id, + asset_id=asset_id, + section_id=next_section_id, + title=next_title, + prompt=next_prompt, + object_key=current.object_key, + file_url=current.file_url, + alt_text=next_alt_text, + caption=next_caption, + status=next_status, + updated_at=now, + ) + revision_index = repository.assets.next_revision_index( + article_id=article_id, + asset_id=asset_id, + ) + repository.assets.create_revision( + article_id=article_id, + asset_id=asset_id, + revision_index=revision_index, + action="ASSET_UPDATED", + actor_user_id=current_user.id, + payload={ + "changed_fields": changed_fields, + "status": next_status.value, + }, + created_at=now, + ) + if current.status != next_status: + repository.articles.create_workflow_event( + article_id=article_id, + event_type="ASSET_STATUS_UPDATED", + from_status=None, + to_status=None, + actor_user_id=current_user.id, + payload={ + "asset_id": str(asset_id), + "from_status": current.status.value, + "to_status": next_status.value, + }, + created_at=now, + ) + refreshed = repository.assets.get(article_id=article_id, asset_id=asset_id) + return AssetResponse(asset=refreshed) + + +def approve_asset( + repository: object, + *, + article_id: UUID, + asset_id: UUID, + current_user: CurrentUser, +) -> AssetResponse: + current = repository.assets.get(article_id=article_id, asset_id=asset_id) + if current.status == AssetStatus.APPROVED: + return AssetResponse(asset=current) + + now = _now() + repository.assets.update( + article_id=article_id, + asset_id=asset_id, + section_id=current.section_id, + title=current.title, + prompt=current.prompt, + object_key=current.object_key, + file_url=current.file_url, + alt_text=current.alt_text, + caption=current.caption, + status=AssetStatus.APPROVED, + updated_at=now, + ) + approved = repository.assets.get(article_id=article_id, asset_id=asset_id) + revision_index = repository.assets.next_revision_index( + article_id=article_id, + asset_id=asset_id, + ) + repository.assets.create_revision( + article_id=article_id, + asset_id=asset_id, + revision_index=revision_index, + action="ASSET_APPROVED", + actor_user_id=current_user.id, + payload={ + "from_status": current.status.value, + "to_status": AssetStatus.APPROVED.value, + "object_key": approved.object_key, + }, + created_at=now, + ) + repository.articles.create_workflow_event( + article_id=article_id, + event_type="ASSET_APPROVED", + from_status=None, + to_status=None, + actor_user_id=current_user.id, + payload={ + "asset_id": str(asset_id), + "from_status": current.status.value, + "to_status": AssetStatus.APPROVED.value, + "object_key": approved.object_key, + "file_url": approved.file_url, + }, + created_at=now, + ) + refreshed = repository.assets.get(article_id=article_id, asset_id=asset_id) + return AssetResponse(asset=refreshed) + + +def upload_asset_file( + repository: object, + *, + article_id: UUID, + asset_id: UUID, + filename: str, + content_type: str | None, + content: bytes, + current_user: CurrentUser, + object_storage: ObjectStorageClient, +) -> AssetUploadResponse: + current = repository.assets.get(article_id=article_id, asset_id=asset_id) + now = _now() + revision_index = repository.assets.next_revision_index( + article_id=article_id, + asset_id=asset_id, + ) + safe_filename = _safe_filename(filename) + object_key = f"articles/{article_id}/assets/{asset_id}/r{revision_index}/{safe_filename}" + file_url = object_storage.put_bytes( + object_key=object_key, + content=content, + content_type=content_type or "application/octet-stream", + ) + had_previous_file = bool(current.object_key or current.file_url) + repository.assets.update( + article_id=article_id, + asset_id=asset_id, + section_id=current.section_id, + title=current.title, + prompt=current.prompt, + object_key=object_key, + file_url=file_url, + alt_text=current.alt_text, + caption=current.caption, + status=AssetStatus.PENDING, + updated_at=now, + ) + action = "ASSET_FILE_REPLACED" if had_previous_file else "ASSET_FILE_UPLOADED" + repository.assets.create_revision( + article_id=article_id, + asset_id=asset_id, + revision_index=revision_index, + action=action, + actor_user_id=current_user.id, + payload={ + "previous_object_key": current.object_key, + "previous_file_url": current.file_url, + "next_object_key": object_key, + "next_file_url": file_url, + "content_type": content_type or "application/octet-stream", + "filename": safe_filename, + }, + created_at=now, + ) + repository.articles.create_workflow_event( + article_id=article_id, + event_type=action, + from_status=None, + to_status=None, + actor_user_id=current_user.id, + payload={ + "asset_id": str(asset_id), + "previous_object_key": current.object_key, + "next_object_key": object_key, + }, + created_at=now, + ) + refreshed = repository.assets.get(article_id=article_id, asset_id=asset_id) + return AssetUploadResponse(asset=refreshed) + + +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 _asset_type_for_section(index: int) -> AssetType: + ordered_types = [ + AssetType.HERO_IMAGE, + AssetType.ARCHITECTURE_DIAGRAM, + AssetType.DIAGRAM, + AssetType.TABLE, + AssetType.FLOWCHART, + AssetType.COMPARISON_MATRIX, + AssetType.INLINE_IMAGE, + ] + return ordered_types[(index - 1) % len(ordered_types)] + + +def _changed_fields( + *, + current: AssetSummary, + section_id: UUID | None, + title: str, + prompt: str | None, + alt_text: str | None, + caption: str | None, + status: AssetStatus, +) -> list[str]: + changed: list[str] = [] + if current.section_id != section_id: + changed.append("section_id") + if current.title != title: + changed.append("title") + if current.prompt != prompt: + changed.append("prompt") + if current.alt_text != alt_text: + changed.append("alt_text") + if current.caption != caption: + changed.append("caption") + if current.status != status: + changed.append("status") + return changed + + +def _safe_filename(value: str) -> str: + filename = Path(value).name.strip() + if not filename: + return "asset.bin" + return filename.replace("/", "_") + + +def _now() -> datetime: + return datetime.now(UTC) diff --git a/apps/backend/src/application/drafts.py b/apps/backend/src/application/drafts.py index 19506e4..6745858 100644 --- a/apps/backend/src/application/drafts.py +++ b/apps/backend/src/application/drafts.py @@ -9,6 +9,7 @@ from src.domain.contracts import ( AgentJobStatus, AgentJobSummary, AgentJobType, + AssetStatus, ArticleWorkflowStatus, DraftAssembleResponse, DraftFaqItem, @@ -51,6 +52,10 @@ def assemble_draft(repository: object, *, article_id: UUID) -> DraftAssembleResp visual_placeholders: list[str] = [] evidence_references: list[str] = [] unsupported_warnings = _collect_job_warnings(section_jobs) + approved_assets_by_section = _approved_assets_by_section( + repository, + article_id=article_id, + ) for section in approved_plan.sections: section_job = successful_jobs_by_section[str(section.id)] @@ -61,6 +66,7 @@ def assemble_draft(repository: object, *, article_id: UUID) -> DraftAssembleResp section_id=section.id, heading=section.heading, payload=payload, + approved_assets=approved_assets_by_section.get(str(section.id), []), ) ) evidence_references.extend(_string_list(payload.get("used_evidence_ids"))) @@ -279,7 +285,11 @@ def _visual_placeholders_for_section( section_id: UUID, heading: str, payload: dict[str, Any], + approved_assets: list[object], ) -> list[str]: + if approved_assets: + return [_approved_asset_markdown(asset) for asset in approved_assets] + suggested_visuals = payload.get("suggested_visuals") placeholders: list[str] = [] if isinstance(suggested_visuals, list): @@ -293,6 +303,30 @@ def _visual_placeholders_for_section( return [f"![Visual placeholder: {heading}](placeholder://{section_id}/1)"] +def _approved_assets_by_section(repository: object, *, article_id: UUID) -> dict[str, list[object]]: + assets = repository.assets.list_for_article(article_id) + by_section: dict[str, list[object]] = {} + for asset in assets: + if asset.status != AssetStatus.APPROVED: + continue + if not asset.section_id: + continue + if not asset.file_url: + continue + by_section.setdefault(str(asset.section_id), []).append(asset) + return by_section + + +def _approved_asset_markdown(asset: object) -> str: + alt = _string_value(getattr(asset, "alt_text", None)) or _string_value( + getattr(asset, "title", None) + ) + src = _string_value(getattr(asset, "file_url", None)) + if not src: + return "" + return f"![{alt}]({src})" + + def _visual_label(value: Any, *, heading: str, fallback_index: int) -> str: if isinstance(value, str) and value.strip(): return value.strip() diff --git a/apps/backend/src/domain/contracts/__init__.py b/apps/backend/src/domain/contracts/__init__.py index 17e2cfc..b7773b4 100644 --- a/apps/backend/src/domain/contracts/__init__.py +++ b/apps/backend/src/domain/contracts/__init__.py @@ -30,7 +30,14 @@ from .models import ( BoundaryQuestionSummary, BoundaryQuestionUpdateRequest, WorkflowEventSummary, + AssetGenerateSpecsResponse, + AssetListResponse, + AssetResponse, + AssetRevisionSummary, AssetSummary, + AssetUpdateRequest, + AssetUploadRequest, + AssetUploadResponse, ClaimSummary, CurrentUser, CurrentUserResponse, @@ -98,10 +105,17 @@ __all__ = [ "BoundaryQuestionSummary", "BoundaryQuestionUpdateRequest", "WorkflowEventSummary", + "AssetGenerateSpecsResponse", + "AssetListResponse", + "AssetResponse", + "AssetRevisionSummary", "ArticleWorkflowStatus", "AssetStatus", "AssetSummary", "AssetType", + "AssetUpdateRequest", + "AssetUploadRequest", + "AssetUploadResponse", "ClaimRiskLevel", "ClaimSummary", "ClaimSupportStatus", diff --git a/apps/backend/src/domain/contracts/enums.py b/apps/backend/src/domain/contracts/enums.py index 4499603..5df962d 100644 --- a/apps/backend/src/domain/contracts/enums.py +++ b/apps/backend/src/domain/contracts/enums.py @@ -97,6 +97,9 @@ class AssetType(str, Enum): HERO_IMAGE = "hero_image" DIAGRAM = "diagram" TABLE = "table" + FLOWCHART = "flowchart" + COMPARISON_MATRIX = "comparison_matrix" + ARCHITECTURE_DIAGRAM = "architecture_diagram" INLINE_IMAGE = "inline_image" diff --git a/apps/backend/src/domain/contracts/models.py b/apps/backend/src/domain/contracts/models.py index f00658c..bedb453 100644 --- a/apps/backend/src/domain/contracts/models.py +++ b/apps/backend/src/domain/contracts/models.py @@ -386,16 +386,32 @@ class DraftSummary(ContractModel): updated_at: datetime +class AssetRevisionSummary(ContractModel): + id: UUID + asset_id: UUID + article_id: UUID + revision_index: int = Field(ge=1) + action: str = Field(min_length=1) + actor_user_id: UUID | None = None + payload: JsonObject = Field(default_factory=dict) + created_at: datetime + + class AssetSummary(ContractModel): id: UUID article_id: UUID + section_id: UUID | None = None asset_type: AssetType title: str = Field(min_length=1) prompt: str | None = None + object_key: str | None = None file_url: str | None = None alt_text: str | None = None caption: str | None = None status: AssetStatus + history: list[AssetRevisionSummary] = Field(default_factory=list) + created_at: datetime + updated_at: datetime class ReviewSummary(ContractModel): @@ -497,6 +513,37 @@ class DraftUpdateRequest(ContractModel): unsupported_claim_warnings: list[str] | None = None +class AssetGenerateSpecsResponse(ContractModel): + assets: list[AssetSummary] = Field(default_factory=list) + + +class AssetListResponse(ContractModel): + assets: list[AssetSummary] = Field(default_factory=list) + + +class AssetResponse(ContractModel): + asset: AssetSummary + + +class AssetUpdateRequest(ContractModel): + title: str | None = Field(default=None, min_length=1) + prompt: str | None = None + alt_text: str | None = None + caption: str | None = None + section_id: UUID | None = None + status: AssetStatus | None = None + + +class AssetUploadRequest(ContractModel): + filename: str = Field(min_length=1) + content_base64: str = Field(min_length=1) + content_type: str | None = None + + +class AssetUploadResponse(ContractModel): + asset: AssetSummary + + 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 6ab2c78..c188422 100644 --- a/apps/backend/src/domain/contracts/openapi.py +++ b/apps/backend/src/domain/contracts/openapi.py @@ -36,7 +36,14 @@ from .models import ( BoundaryQuestionResponse, BoundaryQuestionSummary, BoundaryQuestionUpdateRequest, + AssetGenerateSpecsResponse, + AssetListResponse, + AssetResponse, + AssetRevisionSummary, AssetSummary, + AssetUpdateRequest, + AssetUploadRequest, + AssetUploadResponse, ClaimSummary, CurrentUser, CurrentUserResponse, @@ -142,7 +149,14 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = ( DraftListResponse, DraftResponse, DraftUpdateRequest, + AssetGenerateSpecsResponse, + AssetListResponse, + AssetResponse, + AssetRevisionSummary, AssetSummary, + AssetUpdateRequest, + AssetUploadRequest, + AssetUploadResponse, ReviewSummary, ReviewActionResponse, PublishCommitSummary, diff --git a/apps/backend/src/domain/schema.py b/apps/backend/src/domain/schema.py index 943d964..832b8d1 100644 --- a/apps/backend/src/domain/schema.py +++ b/apps/backend/src/domain/schema.py @@ -12,6 +12,7 @@ EVIDENCE_ITEMS_TABLE = "evidence_items" CLAIMS_TABLE = "claims" ARTICLE_DRAFTS_TABLE = "article_drafts" ASSETS_TABLE = "assets" +ASSET_REVISIONS_TABLE = "asset_revisions" WORKFLOW_EVENTS_TABLE = "workflow_events" AGENT_JOBS_TABLE = "agent_jobs" RESEARCH_RUN_MANIFESTS_TABLE = "research_run_manifests" @@ -31,6 +32,7 @@ CORE_TABLES: tuple[str, ...] = ( CLAIMS_TABLE, ARTICLE_DRAFTS_TABLE, ASSETS_TABLE, + ASSET_REVISIONS_TABLE, WORKFLOW_EVENTS_TABLE, AGENT_JOBS_TABLE, RESEARCH_RUN_MANIFESTS_TABLE, diff --git a/apps/backend/src/infrastructure/object_storage.py b/apps/backend/src/infrastructure/object_storage.py index a20daf7..b7330fc 100644 --- a/apps/backend/src/infrastructure/object_storage.py +++ b/apps/backend/src/infrastructure/object_storage.py @@ -8,6 +8,15 @@ class ObjectStorageClient: def put_text(self, *, object_key: str, content: str) -> str: raise NotImplementedError + def put_bytes( + self, + *, + object_key: str, + content: bytes, + content_type: str = "application/octet-stream", + ) -> str: + raise NotImplementedError + class LocalObjectStorageClient(ObjectStorageClient): def __init__(self, root: Path) -> None: @@ -19,6 +28,19 @@ class LocalObjectStorageClient(ObjectStorageClient): path.write_text(content, encoding="utf-8") return f"file://{path}" + def put_bytes( + self, + *, + object_key: str, + content: bytes, + content_type: str = "application/octet-stream", + ) -> str: + del content_type + path = self.root / object_key + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + return f"file://{path}" + class S3ObjectStorageClient(ObjectStorageClient): def __init__(self) -> None: @@ -36,11 +58,24 @@ class S3ObjectStorageClient(ObjectStorageClient): ) def put_text(self, *, object_key: str, content: str) -> str: + return self.put_bytes( + object_key=object_key, + content=content.encode("utf-8"), + content_type="application/json; charset=utf-8", + ) + + def put_bytes( + self, + *, + object_key: str, + content: bytes, + content_type: str = "application/octet-stream", + ) -> str: self.client.put_object( Bucket=self.bucket, Key=object_key, - Body=content.encode("utf-8"), - ContentType="application/json; charset=utf-8", + Body=content, + ContentType=content_type, ) return f"s3://{self.bucket}/{object_key}" diff --git a/apps/backend/src/infrastructure/repositories.py b/apps/backend/src/infrastructure/repositories.py index 191bae0..fc97a5d 100644 --- a/apps/backend/src/infrastructure/repositories.py +++ b/apps/backend/src/infrastructure/repositories.py @@ -13,6 +13,10 @@ from src.domain.contracts import ( AgentJobStatus, AgentJobSummary, AgentJobType, + AssetRevisionSummary, + AssetStatus, + AssetSummary, + AssetType, ArticleSummary, BoundaryQuestionSummary, ClaimRiskLevel, @@ -55,6 +59,7 @@ class BackendRepository: self.boundary_questions = BoundaryQuestionsRepository(self) self.article_plans = ArticlePlansRepository(self) self.article_drafts = ArticleDraftsRepository(self) + self.assets = AssetsRepository(self) self.research_manifests = ResearchManifestsRepository(self) self.evidence_items = EvidenceItemsRepository(self) self.claims = ClaimsRepository(self) @@ -1192,6 +1197,325 @@ class ArticleDraftsRepository: """ +class AssetsRepository: + def __init__(self, repository: BackendRepository) -> None: + self._repository = repository + + def create( + self, + *, + article_id: UUID, + section_id: UUID | None, + asset_type: AssetType, + title: str, + prompt: str | None, + object_key: str | None, + file_url: str | None, + alt_text: str | None, + caption: str | None, + status: AssetStatus, + created_at: datetime, + updated_at: datetime, + ) -> AssetSummary: + asset_id = uuid4() + placeholder = self._repository.placeholder() + with self._repository.connection() as connection: + connection.execute( + f""" + INSERT INTO assets ( + id, + article_id, + section_id, + asset_type, + title, + prompt, + object_key, + file_url, + alt_text, + caption, + status, + created_at, + updated_at + ) + VALUES ( + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder} + ) + """, + ( + str(asset_id), + str(article_id), + _uuid_value(section_id), + asset_type.value, + title, + prompt, + object_key, + file_url, + alt_text, + caption, + status.value, + _datetime_value(created_at), + _datetime_value(updated_at), + ), + ) + + return self.get(article_id=article_id, asset_id=asset_id) + + def list_for_article(self, article_id: UUID) -> list[AssetSummary]: + placeholder = self._repository.placeholder() + with self._repository.connection() as connection: + rows = connection.execute( + f""" + SELECT + id, + article_id, + section_id, + asset_type, + title, + prompt, + object_key, + file_url, + alt_text, + caption, + status, + created_at, + updated_at + FROM assets + WHERE article_id = {placeholder} + ORDER BY created_at, id + """, + (str(article_id),), + ).fetchall() + + history_by_asset = self._history_by_asset(article_id) + assets: list[AssetSummary] = [] + for row in rows: + asset = _asset_summary_from_row( + row, + history=history_by_asset.get(str(_row_value(row, "id")), []), + ) + assets.append(asset) + return assets + + def get(self, *, article_id: UUID, asset_id: UUID) -> AssetSummary: + placeholder = self._repository.placeholder() + with self._repository.connection() as connection: + row = connection.execute( + f""" + SELECT + id, + article_id, + section_id, + asset_type, + title, + prompt, + object_key, + file_url, + alt_text, + caption, + status, + created_at, + updated_at + FROM assets + WHERE article_id = {placeholder} AND id = {placeholder} + """, + (str(article_id), str(asset_id)), + ).fetchone() + if row is None: + raise LookupError(f"Asset not found: {asset_id}") + history_by_asset = self._history_by_asset(article_id) + return _asset_summary_from_row( + row, + history=history_by_asset.get(str(asset_id), []), + ) + + def update( + self, + *, + article_id: UUID, + asset_id: UUID, + section_id: UUID | None, + title: str, + prompt: str | None, + object_key: str | None, + file_url: str | None, + alt_text: str | None, + caption: str | None, + status: AssetStatus, + updated_at: datetime, + ) -> AssetSummary: + placeholder = self._repository.placeholder() + with self._repository.connection() as connection: + connection.execute( + f""" + UPDATE assets + SET + section_id = {placeholder}, + title = {placeholder}, + prompt = {placeholder}, + object_key = {placeholder}, + file_url = {placeholder}, + alt_text = {placeholder}, + caption = {placeholder}, + status = {placeholder}, + updated_at = {placeholder} + WHERE article_id = {placeholder} AND id = {placeholder} + """, + ( + _uuid_value(section_id), + title, + prompt, + object_key, + file_url, + alt_text, + caption, + status.value, + _datetime_value(updated_at), + str(article_id), + str(asset_id), + ), + ) + + return self.get(article_id=article_id, asset_id=asset_id) + + def next_revision_index(self, *, article_id: UUID, asset_id: UUID) -> int: + placeholder = self._repository.placeholder() + with self._repository.connection() as connection: + row = connection.execute( + f""" + SELECT COALESCE(MAX(revision_index), 0) + 1 AS next_revision + FROM asset_revisions + WHERE article_id = {placeholder} AND asset_id = {placeholder} + """, + (str(article_id), str(asset_id)), + ).fetchone() + return int(_row_value(row, "next_revision")) + + def create_revision( + self, + *, + article_id: UUID, + asset_id: UUID, + revision_index: int, + action: str, + actor_user_id: UUID | None, + payload: JsonObject, + created_at: datetime, + ) -> AssetRevisionSummary: + revision_id = uuid4() + placeholder = self._repository.placeholder() + json_cast = self._repository.json_cast() + with self._repository.connection() as connection: + connection.execute( + f""" + INSERT INTO asset_revisions ( + id, + article_id, + asset_id, + revision_index, + action, + actor_user_id, + payload, + created_at + ) + VALUES ( + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}{json_cast}, + {placeholder} + ) + """, + ( + str(revision_id), + str(article_id), + str(asset_id), + revision_index, + action, + _uuid_value(actor_user_id), + _json_value(payload), + _datetime_value(created_at), + ), + ) + + return self.get_revision( + article_id=article_id, + asset_id=asset_id, + revision_id=revision_id, + ) + + def get_revision( + self, + *, + article_id: UUID, + asset_id: UUID, + revision_id: UUID, + ) -> AssetRevisionSummary: + placeholder = self._repository.placeholder() + with self._repository.connection() as connection: + row = connection.execute( + f""" + SELECT + id, + article_id, + asset_id, + revision_index, + action, + actor_user_id, + payload, + created_at + FROM asset_revisions + WHERE article_id = {placeholder} + AND asset_id = {placeholder} + AND id = {placeholder} + """, + (str(article_id), str(asset_id), str(revision_id)), + ).fetchone() + if row is None: + raise LookupError(f"Asset revision not found: {revision_id}") + return _asset_revision_from_row(row) + + def _history_by_asset(self, article_id: UUID) -> dict[str, list[AssetRevisionSummary]]: + placeholder = self._repository.placeholder() + with self._repository.connection() as connection: + rows = connection.execute( + f""" + SELECT + id, + article_id, + asset_id, + revision_index, + action, + actor_user_id, + payload, + created_at + FROM asset_revisions + WHERE article_id = {placeholder} + ORDER BY revision_index, created_at, id + """, + (str(article_id),), + ).fetchall() + history: dict[str, list[AssetRevisionSummary]] = {} + for row in rows: + asset_id = str(_row_value(row, "asset_id")) + history.setdefault(asset_id, []).append(_asset_revision_from_row(row)) + return history + + class ResearchManifestsRepository: def __init__(self, repository: BackendRepository) -> None: self._repository = repository @@ -2383,6 +2707,42 @@ def _draft_summary_from_row(row: Any) -> DraftSummary: ) +def _asset_revision_from_row(row: Any) -> AssetRevisionSummary: + return AssetRevisionSummary( + id=_row_value(row, "id"), + article_id=_row_value(row, "article_id"), + asset_id=_row_value(row, "asset_id"), + revision_index=_row_value(row, "revision_index"), + action=_row_value(row, "action"), + actor_user_id=_row_value(row, "actor_user_id"), + payload=_json_from_row(row, "payload"), + created_at=_row_value(row, "created_at"), + ) + + +def _asset_summary_from_row( + row: Any, + *, + history: list[AssetRevisionSummary] | None = None, +) -> AssetSummary: + return AssetSummary( + id=_row_value(row, "id"), + article_id=_row_value(row, "article_id"), + section_id=_row_value(row, "section_id"), + asset_type=_row_value(row, "asset_type"), + title=_row_value(row, "title"), + prompt=_row_value(row, "prompt"), + object_key=_row_value(row, "object_key"), + file_url=_row_value(row, "file_url"), + alt_text=_row_value(row, "alt_text"), + caption=_row_value(row, "caption"), + status=_row_value(row, "status"), + history=history or [], + 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 4acf2a6..7b5c0cb 100644 --- a/apps/backend/src/infrastructure/schema.py +++ b/apps/backend/src/infrastructure/schema.py @@ -237,9 +237,11 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = ( CREATE TABLE IF NOT EXISTS assets ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE, + section_id UUID REFERENCES plan_sections(id) ON DELETE SET NULL, asset_type TEXT NOT NULL CHECK (asset_type IN ({ASSET_TYPE_VALUES})), title TEXT NOT NULL, prompt TEXT, + object_key TEXT, file_url TEXT, alt_text TEXT, caption TEXT, @@ -248,6 +250,20 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """, + "ALTER TABLE assets ADD COLUMN IF NOT EXISTS section_id UUID REFERENCES plan_sections(id) ON DELETE SET NULL", + "ALTER TABLE assets ADD COLUMN IF NOT EXISTS object_key TEXT", + f""" + CREATE TABLE IF NOT EXISTS asset_revisions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE, + asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + revision_index INTEGER NOT NULL, + action TEXT NOT NULL, + actor_user_id UUID REFERENCES users(id), + payload JSONB NOT NULL DEFAULT '{{}}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """, f""" CREATE TABLE IF NOT EXISTS workflow_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -370,6 +386,10 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = ( ON workflow_events (article_id, created_at) """, """ + CREATE INDEX IF NOT EXISTS idx_asset_revisions_asset + ON asset_revisions (asset_id, revision_index, created_at) + """, + """ CREATE INDEX IF NOT EXISTS idx_research_run_manifests_article ON research_run_manifests (article_id, created_at) """, @@ -553,9 +573,11 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = ( CREATE TABLE IF NOT EXISTS assets ( id TEXT PRIMARY KEY, article_id TEXT NOT NULL, + section_id TEXT, asset_type TEXT NOT NULL, title TEXT NOT NULL, prompt TEXT, + object_key TEXT, file_url TEXT, alt_text TEXT, caption TEXT, @@ -565,6 +587,18 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = ( ) """, """ + CREATE TABLE IF NOT EXISTS asset_revisions ( + id TEXT PRIMARY KEY, + article_id TEXT NOT NULL, + asset_id TEXT NOT NULL, + revision_index INTEGER NOT NULL, + action TEXT NOT NULL, + actor_user_id TEXT, + payload TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """, + """ CREATE TABLE IF NOT EXISTS workflow_events ( id TEXT PRIMARY KEY, article_id TEXT NOT NULL, @@ -676,6 +710,10 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = ( ON workflow_events (article_id, created_at) """, """ + CREATE INDEX IF NOT EXISTS idx_asset_revisions_asset + ON asset_revisions (asset_id, revision_index, created_at) + """, + """ CREATE INDEX IF NOT EXISTS idx_research_run_manifests_article ON research_run_manifests (article_id, created_at) """, diff --git a/apps/backend/src/presentation/main.py b/apps/backend/src/presentation/main.py index d2375a0..906a23c 100644 --- a/apps/backend/src/presentation/main.py +++ b/apps/backend/src/presentation/main.py @@ -10,6 +10,7 @@ from src.presentation.routes.agent_jobs import ( ) from src.presentation.routes.agent_jobs import router as agent_jobs_router from src.presentation.routes.articles import router as articles_router +from src.presentation.routes.assets import router as assets_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 @@ -21,6 +22,7 @@ from src.presentation.routes.sites import router as sites_router app = FastAPI(title="AI Content Pipeline Backend") app.include_router(auth_router) app.include_router(articles_router) +app.include_router(assets_router) app.include_router(boundary_questions_router) app.include_router(plans_router) app.include_router(evidence_router) diff --git a/apps/backend/src/presentation/routes/assets.py b/apps/backend/src/presentation/routes/assets.py new file mode 100644 index 0000000..dfad6fc --- /dev/null +++ b/apps/backend/src/presentation/routes/assets.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import base64 +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status + +from src.application.assets import ( + approve_asset, + generate_asset_specs, + list_assets, + update_asset, + upload_asset_file, +) +from src.domain.auth import EDITOR_OR_ADMIN_ROLES +from src.domain.contracts import ( + AssetGenerateSpecsResponse, + AssetListResponse, + AssetResponse, + AssetUpdateRequest, + AssetUploadRequest, + AssetUploadResponse, + CurrentUser, +) +from src.infrastructure.object_storage import open_object_storage_client +from src.infrastructure.repositories import BackendRepository +from src.presentation.dependencies import get_repository, require_roles + + +router = APIRouter(prefix="/api", tags=["assets"]) + + +@router.post( + "/articles/{article_id}/assets/generate-specs", + response_model=AssetGenerateSpecsResponse, + status_code=status.HTTP_201_CREATED, +) +def post_generate_asset_specs( + article_id: UUID, + current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> AssetGenerateSpecsResponse: + try: + return generate_asset_specs( + 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}/assets", + response_model=AssetListResponse, +) +def get_article_assets( + article_id: UUID, + _: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> AssetListResponse: + try: + return list_assets(repository, article_id=article_id) + except LookupError as error: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error + + +@router.patch( + "/articles/{article_id}/assets/{asset_id}", + response_model=AssetResponse, +) +def patch_article_asset( + article_id: UUID, + asset_id: UUID, + request: AssetUpdateRequest, + current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> AssetResponse: + try: + return update_asset( + repository, + article_id=article_id, + asset_id=asset_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}/assets/{asset_id}/approve", + response_model=AssetResponse, +) +def post_approve_asset( + article_id: UUID, + asset_id: UUID, + current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> AssetResponse: + try: + return approve_asset( + repository, + article_id=article_id, + asset_id=asset_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}/assets/{asset_id}/upload", + response_model=AssetUploadResponse, +) +def post_upload_asset_file( + article_id: UUID, + asset_id: UUID, + request: AssetUploadRequest, + current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> AssetUploadResponse: + try: + payload = base64.b64decode(request.content_base64.encode("utf-8"), validate=True) + except ValueError as error: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Invalid base64 payload", + ) from error + if not payload: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Uploaded file is empty", + ) + try: + return upload_asset_file( + repository, + article_id=article_id, + asset_id=asset_id, + filename=request.filename, + content_type=request.content_type, + content=payload, + current_user=current_user, + object_storage=open_object_storage_client(), + ) + except LookupError as error: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error diff --git a/apps/backend/tests/integration/test_assets_media_library_public_api.py b/apps/backend/tests/integration/test_assets_media_library_public_api.py new file mode 100644 index 0000000..9811dc6 --- /dev/null +++ b/apps/backend/tests/integration/test_assets_media_library_public_api.py @@ -0,0 +1,366 @@ +from __future__ import annotations + +import base64 +import os +import sys +import tempfile +import unittest +from pathlib import Path +from typing import Any +from uuid import UUID + +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 AssetsMediaLibraryPublicApiTest(unittest.TestCase): + def setUp(self) -> None: + self.tmp_dir = tempfile.TemporaryDirectory() + self.objects_root = Path(self.tmp_dir.name) / "objects" + os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(self.objects_root) + dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'assets-media-library.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_generate_specs_links_assets_to_approved_plan_sections(self) -> None: + article_id, plan = self._prepare_article_with_approved_plan() + + response = self.client.post( + f"/api/articles/{article_id}/assets/generate-specs", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(201, response.status_code, response.text) + assets = response.json()["assets"] + self.assertEqual(len(plan["sections"]), len(assets)) + section_ids = {section["id"] for section in plan["sections"]} + for asset in assets: + self.assertIn(asset["section_id"], section_ids) + self.assertEqual("GENERATED", asset["status"]) + self.assertTrue(asset["title"]) + + def test_upload_stores_object_and_persists_object_key(self) -> None: + article_id = self._prepare_article_with_assembled_draft() + asset_id = self._generate_specs(article_id)[0]["id"] + + upload_response = self.client.post( + f"/api/articles/{article_id}/assets/{asset_id}/upload", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json={ + "filename": "hero.png", + "content_base64": base64.b64encode(b"fake-binary-image-content").decode("utf-8"), + "content_type": "image/png", + }, + ) + self.assertEqual(200, upload_response.status_code, upload_response.text) + asset = upload_response.json()["asset"] + self.assertEqual("PENDING", asset["status"]) + self.assertTrue(asset["object_key"]) + self.assertTrue(asset["file_url"].startswith("file://")) + self.assertTrue( + (self.objects_root / asset["object_key"]).exists(), + "Uploaded object must exist in local object storage", + ) + persisted = (self.objects_root / asset["object_key"]).read_bytes() + self.assertEqual(b"fake-binary-image-content", persisted) + history = asset["history"] + self.assertGreaterEqual(len(history), 1) + self.assertEqual("ASSET_FILE_UPLOADED", history[-1]["action"]) + + def test_approve_asset_writes_workflow_event(self) -> None: + article_id = self._prepare_article_with_assembled_draft() + asset_id = self._generate_specs(article_id)[0]["id"] + self._upload_asset(article_id, asset_id, filename="approve.png", payload=b"approve") + + approve_response = self.client.post( + f"/api/articles/{article_id}/assets/{asset_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()["asset"]["status"]) + + article_detail = self.client.get( + f"/api/articles/{article_id}", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(200, article_detail.status_code, article_detail.text) + events = article_detail.json()["workflow_events"] + approval_events = [event for event in events if event["event_type"] == "ASSET_APPROVED"] + self.assertTrue(approval_events, "Approval must write workflow event") + self.assertEqual(asset_id, approval_events[-1]["payload"]["asset_id"]) + + def test_rejected_assets_are_excluded_from_draft_publish_bundle(self) -> None: + article_id = self._prepare_article_with_completed_scaffolds() + generated_assets = self._generate_specs(article_id) + self.assertGreaterEqual(len(generated_assets), 2) + approved_asset = generated_assets[0] + rejected_asset = generated_assets[1] + + approved_upload = self._upload_asset( + article_id, + approved_asset["id"], + filename="approved.png", + payload=b"approved-image", + ) + self._approve_asset(article_id, approved_asset["id"]) + rejected_upload = self._upload_asset( + article_id, + rejected_asset["id"], + filename="rejected.png", + payload=b"rejected-image", + ) + self._patch_asset( + article_id, + rejected_asset["id"], + request={"status": "REJECTED"}, + ) + + draft = self._assemble_draft(article_id) + self.assertIn(approved_upload["file_url"], draft["body_markdown"]) + self.assertNotIn(rejected_upload["file_url"], draft["body_markdown"]) + self.assertIn(approved_upload["file_url"], "\n".join(draft["visual_placeholders"])) + self.assertNotIn(rejected_upload["file_url"], "\n".join(draft["visual_placeholders"])) + + def test_replacement_preserves_audit_history(self) -> None: + article_id = self._prepare_article_with_assembled_draft() + asset_id = self._generate_specs(article_id)[0]["id"] + first = self._upload_asset(article_id, asset_id, filename="first.png", payload=b"first") + second = self._upload_asset(article_id, asset_id, filename="second.png", payload=b"second") + self.assertNotEqual(first["object_key"], second["object_key"]) + + list_response = self.client.get( + f"/api/articles/{article_id}/assets", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(200, list_response.status_code, list_response.text) + asset = next(item for item in list_response.json()["assets"] if item["id"] == asset_id) + history = asset["history"] + actions = [item["action"] for item in history] + self.assertIn("ASSET_FILE_UPLOADED", actions) + self.assertIn("ASSET_FILE_REPLACED", actions) + replaced_event = next(item for item in history if item["action"] == "ASSET_FILE_REPLACED") + self.assertEqual(first["object_key"], replaced_event["payload"]["previous_object_key"]) + self.assertEqual(second["object_key"], replaced_event["payload"]["next_object_key"]) + + def test_media_library_lists_assets_with_required_fields(self) -> None: + article_id = self._prepare_article_with_assembled_draft() + asset_id = self._generate_specs(article_id)[0]["id"] + self._upload_asset(article_id, asset_id, filename="library.png", payload=b"library") + self._patch_asset( + article_id, + asset_id, + request={ + "title": "Library Hero", + "alt_text": "Dashboard on laptop screen", + "caption": "Screenshot used in introduction section.", + }, + ) + + list_response = self.client.get( + f"/api/articles/{article_id}/assets", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(200, list_response.status_code, list_response.text) + asset = next(item for item in list_response.json()["assets"] if item["id"] == asset_id) + self.assertEqual("Library Hero", asset["title"]) + self.assertTrue(asset["asset_type"]) + self.assertTrue(asset["status"]) + self.assertTrue(asset["file_url"]) + self.assertEqual("Dashboard on laptop screen", asset["alt_text"]) + self.assertEqual("Screenshot used in introduction section.", asset["caption"]) + + def test_draft_preview_uses_approved_asset_references(self) -> None: + article_id = self._prepare_article_with_completed_scaffolds() + asset = self._generate_specs(article_id)[0] + uploaded = self._upload_asset( + article_id, + asset["id"], + filename="preview.png", + payload=b"preview", + ) + self._approve_asset(article_id, asset["id"]) + + draft = self._assemble_draft(article_id) + self.assertIn(uploaded["file_url"], draft["body_markdown"]) + self.assertIn(uploaded["file_url"], "\n".join(draft["visual_placeholders"])) + + def _assemble_draft(self, article_id: UUID) -> 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 _upload_asset(self, article_id: UUID, asset_id: str, *, filename: str, payload: bytes) -> dict[str, Any]: + response = self.client.post( + f"/api/articles/{article_id}/assets/{asset_id}/upload", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json={ + "filename": filename, + "content_base64": base64.b64encode(payload).decode("utf-8"), + "content_type": "image/png", + }, + ) + self.assertEqual(200, response.status_code, response.text) + return response.json()["asset"] + + def _approve_asset(self, article_id: UUID, asset_id: str) -> None: + response = self.client.post( + f"/api/articles/{article_id}/assets/{asset_id}/approve", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(200, response.status_code, response.text) + + def _patch_asset(self, article_id: UUID, asset_id: str, *, request: dict[str, Any]) -> dict[str, Any]: + response = self.client.patch( + f"/api/articles/{article_id}/assets/{asset_id}", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json=request, + ) + self.assertEqual(200, response.status_code, response.text) + return response.json()["asset"] + + def _generate_specs(self, article_id: UUID) -> list[dict[str, Any]]: + response = self.client.post( + f"/api/articles/{article_id}/assets/generate-specs", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(201, response.status_code, response.text) + return response.json()["assets"] + + def _prepare_article_with_assembled_draft(self) -> UUID: + article_id = self._prepare_article_with_completed_scaffolds() + self._assemble_draft(article_id) + return article_id + + def _prepare_article_with_completed_scaffolds(self) -> UUID: + article_id, _ = self._prepare_article_with_approved_plan() + self._ensure_evidence_ready(article_id) + section_jobs = self._start_parallel_production(article_id) + self._complete_all_section_jobs_success(section_jobs) + return article_id + + def _prepare_article_with_approved_plan(self) -> tuple[UUID, dict[str, Any]]: + 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": "Assets library integration coverage", + "working_title": "Assets and Media Library", + "content_type": "longform_guide", + "primary_keyword": "assets media library", + }, + ).json()["article"] + article_id = UUID(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_plan_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_plan_response.status_code, approve_plan_response.text) + return article_id, plan + + def _complete_all_section_jobs_success(self, section_jobs: list[dict[str, Any]]) -> None: + for index, job in enumerate(section_jobs, start=1): + complete_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": { + "status": "SUCCEEDED", + "output_files": [{"path": f"outputs/section-{index}.md"}], + "payload": { + "used_evidence_ids": job["payload"]["used_evidence_ids"], + "unsupported_claims": [], + "draft_markdown": f"## {job['payload']['heading']}\n\nSection {index}.", + }, + }, + }, + ) + self.assertEqual(200, complete_response.status_code, complete_response.text) + self.assertEqual("SUCCEEDED", complete_response.json()["job"]["status"]) + + def _start_parallel_production(self, article_id: UUID) -> list[dict[str, Any]]: + response = self.client.post( + f"/api/articles/{article_id}/draft/start", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(202, response.status_code, response.text) + jobs = response.json()["jobs"] + section_jobs = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"] + self.assertTrue(section_jobs) + return section_jobs + + def _ensure_evidence_ready(self, article_id: UUID) -> 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"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/frontend/src/features/article-assets/model.ts b/apps/frontend/src/features/article-assets/model.ts new file mode 100644 index 0000000..a0153dc --- /dev/null +++ b/apps/frontend/src/features/article-assets/model.ts @@ -0,0 +1,71 @@ +import type { AssetSummary } from "@pipeline/shared"; + +export type MediaLibraryRow = { + id: string; + sectionId: string | null; + status: string; + type: string; + title: string; + preview: string | null; + altText: string | null; + caption: string | null; + publishReady: boolean; +}; + +export function buildMediaLibraryRows(assets: readonly AssetSummary[]): MediaLibraryRow[] { + return [...assets] + .map((asset) => ({ + id: asset.id, + sectionId: asset.section_id ?? null, + status: asset.status, + type: asset.asset_type, + title: asset.title, + preview: asset.file_url ?? null, + altText: asset.alt_text ?? null, + caption: asset.caption ?? null, + publishReady: asset.status === "APPROVED" && Boolean(asset.file_url), + })) + .sort((left, right) => left.title.localeCompare(right.title)); +} + +export function resolveDraftPreviewMarkdown( + markdown: string, + assets: readonly AssetSummary[], +): string { + if (!markdown) { + return ""; + } + const approvedBySection = approvedAssetMapBySection(assets); + return markdown.replace( + /!\[([^\]]*)\]\(placeholder:\/\/([^/]+)\/(\d+)\)/g, + (_full, alt: string, sectionId: string, indexText: string) => { + const index = Number.parseInt(indexText, 10); + const sectionAssets = approvedBySection.get(sectionId) ?? []; + if (!Number.isFinite(index) || index < 1 || index > sectionAssets.length) { + return _full; + } + const asset = sectionAssets[index - 1]; + const resolvedAlt = asset.alt_text?.trim() || asset.title || alt || "asset"; + return `![${resolvedAlt}](${asset.file_url})`; + }, + ); +} + +function approvedAssetMapBySection( + assets: readonly AssetSummary[], +): Map { + const map = new Map(); + for (const asset of assets) { + if (!asset.section_id || asset.status !== "APPROVED" || !asset.file_url) { + continue; + } + const list = map.get(asset.section_id) ?? []; + list.push(asset); + map.set(asset.section_id, list); + } + for (const [sectionId, list] of map.entries()) { + list.sort((left, right) => left.created_at.localeCompare(right.created_at)); + map.set(sectionId, list); + } + return map; +} diff --git a/apps/frontend/src/features/article-assets/ui.tsx b/apps/frontend/src/features/article-assets/ui.tsx new file mode 100644 index 0000000..9b2587d --- /dev/null +++ b/apps/frontend/src/features/article-assets/ui.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useMemo } from "react"; + +import type { AssetSummary } from "@pipeline/shared"; + +import { buildMediaLibraryRows } from "./model"; + +type MediaLibraryPanelProps = { + assets: readonly AssetSummary[]; +}; + +export function MediaLibraryPanel({ assets }: MediaLibraryPanelProps) { + const rows = useMemo(() => buildMediaLibraryRows(assets), [assets]); + return ( +
+

Media library

+ + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + ))} + {rows.length === 0 ? ( + + + + ) : null} + +
TitleTypeStatusPreviewAlt textCaption
{row.title}{row.type}{row.status} + {row.preview ? ( + + file + + ) : ( + "—" + )} + {row.altText ?? "—"}{row.caption ?? "—"}
No assets yet.
+
+ ); +} diff --git a/apps/frontend/src/features/article-draft/ui.tsx b/apps/frontend/src/features/article-draft/ui.tsx index 73493e0..825bba5 100644 --- a/apps/frontend/src/features/article-draft/ui.tsx +++ b/apps/frontend/src/features/article-draft/ui.tsx @@ -3,8 +3,10 @@ import { useMemo, useState } from "react"; import type { DraftSummary } from "@pipeline/shared"; +import type { AssetSummary } from "@pipeline/shared"; import { ApiError, assembleDraft, updateDraft } from "@/shared/pipeline-api"; +import { resolveDraftPreviewMarkdown } from "@/features/article-assets/model"; import { compareDraftVersions, parseDraftPreview, @@ -14,6 +16,7 @@ import { type DraftEditorPanelProps = { articleId: string; initialDrafts: readonly DraftSummary[]; + assets: readonly AssetSummary[]; }; type DraftEditorState = { @@ -23,7 +26,7 @@ type DraftEditorState = { bodyMarkdown: string; }; -export function DraftEditorPanel({ articleId, initialDrafts }: DraftEditorPanelProps) { +export function DraftEditorPanel({ articleId, initialDrafts, assets }: DraftEditorPanelProps) { const [drafts, setDrafts] = useState(() => [...initialDrafts]); const [selectedDraftId, setSelectedDraftId] = useState( initialDrafts[0]?.id ?? null, @@ -44,8 +47,12 @@ export function DraftEditorPanel({ articleId, initialDrafts }: DraftEditorPanelP [drafts, compareDraftId], ); const previewBlocks = useMemo( - () => parseDraftPreview(editor.bodyMarkdown, selectedDraft?.faq_items ?? []), - [editor.bodyMarkdown, selectedDraft], + () => + parseDraftPreview( + resolveDraftPreviewMarkdown(editor.bodyMarkdown, assets), + selectedDraft?.faq_items ?? [], + ), + [assets, editor.bodyMarkdown, selectedDraft], ); const comparison = useMemo(() => { if (!selectedDraft || !compareDraft || selectedDraft.id === compareDraft.id) { diff --git a/apps/frontend/src/pages/article-detail/index.tsx b/apps/frontend/src/pages/article-detail/index.tsx index a0c97f7..09006b9 100644 --- a/apps/frontend/src/pages/article-detail/index.tsx +++ b/apps/frontend/src/pages/article-detail/index.tsx @@ -6,6 +6,7 @@ import { } from "@/features/article-detail/model"; import { ArticleDetailShell } from "@/features/article-detail/ui"; import { DraftEditorPanel } from "@/features/article-draft/ui"; +import { MediaLibraryPanel } from "@/features/article-assets/ui"; import { fetchArticleDetail, fetchDrafts } from "@/shared/pipeline-api"; import { ApiError } from "@/shared/pipeline-api"; @@ -35,7 +36,12 @@ export default async function ArticleDetailPage({
- + +
); diff --git a/apps/frontend/src/shared/pipeline-api.ts b/apps/frontend/src/shared/pipeline-api.ts index d6f392c..f85abf1 100644 --- a/apps/frontend/src/shared/pipeline-api.ts +++ b/apps/frontend/src/shared/pipeline-api.ts @@ -1,4 +1,9 @@ import type { + AssetGenerateSpecsResponse, + AssetListResponse, + AssetResponse, + AssetUpdateRequest, + AssetUploadResponse, ArticleCreateRequest, ArticleCreateResponse, ArticleDetailResponse, @@ -459,3 +464,62 @@ export function updateDraft( request, ); } + +export function generateAssetSpecs(articleId: string): Promise { + return apiPost( + `/api/articles/${articleId}/assets/generate-specs`, + undefined, + ); +} + +export function fetchAssets(articleId: string): Promise { + return apiGet(`/api/articles/${articleId}/assets`); +} + +export function updateAsset( + articleId: string, + assetId: string, + request: AssetUpdateRequest, +): Promise { + return apiPatch( + `/api/articles/${articleId}/assets/${assetId}`, + request, + ); +} + +export function approveAsset(articleId: string, assetId: string): Promise { + return apiPost( + `/api/articles/${articleId}/assets/${assetId}/approve`, + undefined, + ); +} + +export function uploadAssetFile( + articleId: string, + assetId: string, + file: File, +): Promise { + return file.arrayBuffer().then((buffer) => { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let index = 0; index < bytes.length; index += 1) { + binary += String.fromCharCode(bytes[index]); + } + if (typeof btoa !== "function") { + throw new Error("Base64 encoding is not available in this runtime"); + } + const content_base64 = btoa(binary); + return requestJson( + `/api/articles/${articleId}/assets/${assetId}/upload`, + { + method: "POST", + body: JSON.stringify({ + filename: file.name || "asset.bin", + content_base64, + content_type: file.type || "application/octet-stream", + }), + }, + DEMO_EDITOR_EMAIL, + ); + }); +} diff --git a/apps/frontend/tests/article_assets.model.test.mjs b/apps/frontend/tests/article_assets.model.test.mjs new file mode 100644 index 0000000..d45b00c --- /dev/null +++ b/apps/frontend/tests/article_assets.model.test.mjs @@ -0,0 +1,70 @@ +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/article-assets/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 { buildMediaLibraryRows, resolveDraftPreviewMarkdown } = moduleExports; + +const assets = [ + { + id: "asset-approved", + article_id: "article-1", + section_id: "section-1", + asset_type: "hero_image", + title: "Hero visual", + prompt: "hero prompt", + object_key: "articles/article-1/assets/asset-approved/r2/hero.png", + file_url: "file:///tmp/hero.png", + alt_text: "Approved hero visual", + caption: "Used for section one", + status: "APPROVED", + history: [], + created_at: "2026-05-21T00:00:00Z", + updated_at: "2026-05-21T00:00:00Z", + }, + { + id: "asset-rejected", + article_id: "article-1", + section_id: "section-1", + asset_type: "diagram", + title: "Rejected visual", + prompt: "diagram prompt", + object_key: "articles/article-1/assets/asset-rejected/r1/rejected.png", + file_url: "file:///tmp/rejected.png", + alt_text: "Rejected visual", + caption: "Do not publish", + status: "REJECTED", + history: [], + created_at: "2026-05-21T00:00:01Z", + updated_at: "2026-05-21T00:00:01Z", + }, +]; + +const rows = buildMediaLibraryRows(assets); +assert.equal(rows.length, 2); +assert.equal(rows[0].title, "Hero visual"); +assert.equal(rows[0].publishReady, true); +assert.equal(rows[1].publishReady, false); + +const markdown = [ + "## Intro", + "", + "![Visual placeholder: Intro](placeholder://section-1/1)", + "![Visual placeholder: Intro 2](placeholder://section-1/2)", +].join("\n"); +const resolved = resolveDraftPreviewMarkdown(markdown, assets); +assert.ok(resolved.includes("file:///tmp/hero.png")); +assert.ok(resolved.includes("placeholder://section-1/2")); +assert.ok(!resolved.includes("file:///tmp/rejected.png")); diff --git a/packages/shared/openapi.json b/packages/shared/openapi.json index 5e78811..97d159a 100644 --- a/packages/shared/openapi.json +++ b/packages/shared/openapi.json @@ -655,6 +655,110 @@ "title": "ArticleWorkflowStatus", "type": "string" }, + "AssetGenerateSpecsResponse": { + "additionalProperties": false, + "properties": { + "assets": { + "items": { + "$ref": "#/components/schemas/AssetSummary" + }, + "title": "Assets", + "type": "array" + } + }, + "title": "AssetGenerateSpecsResponse", + "type": "object" + }, + "AssetListResponse": { + "additionalProperties": false, + "properties": { + "assets": { + "items": { + "$ref": "#/components/schemas/AssetSummary" + }, + "title": "Assets", + "type": "array" + } + }, + "title": "AssetListResponse", + "type": "object" + }, + "AssetResponse": { + "additionalProperties": false, + "properties": { + "asset": { + "$ref": "#/components/schemas/AssetSummary" + } + }, + "required": [ + "asset" + ], + "title": "AssetResponse", + "type": "object" + }, + "AssetRevisionSummary": { + "additionalProperties": false, + "properties": { + "action": { + "minLength": 1, + "title": "Action", + "type": "string" + }, + "actor_user_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actor User Id" + }, + "article_id": { + "format": "uuid", + "title": "Article Id", + "type": "string" + }, + "asset_id": { + "format": "uuid", + "title": "Asset Id", + "type": "string" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "payload": { + "additionalProperties": true, + "title": "Payload", + "type": "object" + }, + "revision_index": { + "minimum": 1, + "title": "Revision Index", + "type": "integer" + } + }, + "required": [ + "id", + "asset_id", + "article_id", + "revision_index", + "action", + "created_at" + ], + "title": "AssetRevisionSummary", + "type": "object" + }, "AssetStatus": { "enum": [ "PENDING", @@ -700,6 +804,11 @@ "default": null, "title": "Caption" }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, "file_url": { "anyOf": [ { @@ -712,11 +821,30 @@ "default": null, "title": "File Url" }, + "history": { + "items": { + "$ref": "#/components/schemas/AssetRevisionSummary" + }, + "title": "History", + "type": "array" + }, "id": { "format": "uuid", "title": "Id", "type": "string" }, + "object_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Object Key" + }, "prompt": { "anyOf": [ { @@ -729,6 +857,19 @@ "default": null, "title": "Prompt" }, + "section_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Section Id" + }, "status": { "$ref": "#/components/schemas/AssetStatus" }, @@ -736,6 +877,11 @@ "minLength": 1, "title": "Title", "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" } }, "required": [ @@ -743,7 +889,9 @@ "article_id", "asset_type", "title", - "status" + "status", + "created_at", + "updated_at" ], "title": "AssetSummary", "type": "object" @@ -753,11 +901,140 @@ "hero_image", "diagram", "table", + "flowchart", + "comparison_matrix", + "architecture_diagram", "inline_image" ], "title": "AssetType", "type": "string" }, + "AssetUpdateRequest": { + "additionalProperties": false, + "properties": { + "alt_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Alt Text" + }, + "caption": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Caption" + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Prompt" + }, + "section_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Section Id" + }, + "status": { + "anyOf": [ + { + "$ref": "#/components/schemas/AssetStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "title": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + } + }, + "title": "AssetUpdateRequest", + "type": "object" + }, + "AssetUploadRequest": { + "additionalProperties": false, + "properties": { + "content_base64": { + "minLength": 1, + "title": "Content Base64", + "type": "string" + }, + "content_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Content Type" + }, + "filename": { + "minLength": 1, + "title": "Filename", + "type": "string" + } + }, + "required": [ + "filename", + "content_base64" + ], + "title": "AssetUploadRequest", + "type": "object" + }, + "AssetUploadResponse": { + "additionalProperties": false, + "properties": { + "asset": { + "$ref": "#/components/schemas/AssetSummary" + } + }, + "required": [ + "asset" + ], + "title": "AssetUploadResponse", + "type": "object" + }, "BoundaryQuestionListResponse": { "additionalProperties": false, "properties": { @@ -3648,6 +3925,351 @@ ] } }, + "/api/articles/{article_id}/assets": { + "get": { + "operationId": "get_article_assets_api_articles__article_id__assets_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/AssetListResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Article Assets", + "tags": [ + "assets" + ] + } + }, + "/api/articles/{article_id}/assets/generate-specs": { + "post": { + "operationId": "post_generate_asset_specs_api_articles__article_id__assets_generate_specs_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/AssetGenerateSpecsResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Post Generate Asset Specs", + "tags": [ + "assets" + ] + } + }, + "/api/articles/{article_id}/assets/{asset_id}": { + "patch": { + "operationId": "patch_article_asset_api_articles__article_id__assets__asset_id__patch", + "parameters": [ + { + "in": "path", + "name": "article_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Article Id", + "type": "string" + } + }, + { + "in": "path", + "name": "asset_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Asset 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/AssetUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Patch Article Asset", + "tags": [ + "assets" + ] + } + }, + "/api/articles/{article_id}/assets/{asset_id}/approve": { + "post": { + "operationId": "post_approve_asset_api_articles__article_id__assets__asset_id__approve_post", + "parameters": [ + { + "in": "path", + "name": "article_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Article Id", + "type": "string" + } + }, + { + "in": "path", + "name": "asset_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Asset 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/AssetResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Post Approve Asset", + "tags": [ + "assets" + ] + } + }, + "/api/articles/{article_id}/assets/{asset_id}/upload": { + "post": { + "operationId": "post_upload_asset_file_api_articles__article_id__assets__asset_id__upload_post", + "parameters": [ + { + "in": "path", + "name": "article_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Article Id", + "type": "string" + } + }, + { + "in": "path", + "name": "asset_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Asset 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/AssetUploadRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetUploadResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Post Upload Asset File", + "tags": [ + "assets" + ] + } + }, "/api/articles/{article_id}/boundary-questions": { "get": { "operationId": "get_boundary_questions_api_articles__article_id__boundary_questions_get", diff --git a/packages/shared/src/api-types.ts b/packages/shared/src/api-types.ts index 5ccb9b0..63f4eea 100644 --- a/packages/shared/src/api-types.ts +++ b/packages/shared/src/api-types.ts @@ -102,6 +102,29 @@ export type ArticleSummary = { export type ArticleWorkflowStatus = "ARTICLE_BRIEF_CREATED" | "BOUNDARY_QUESTIONS_GENERATED" | "BOUNDARY_ANSWERS_SUBMITTED" | "PLAN_GENERATED" | "PLAN_REVIEW_REQUIRED" | "PLAN_REVISION_REQUIRED" | "RESEARCH_RUNNING" | "EVIDENCE_MATRIX_READY" | "PARALLEL_PRODUCTION_RUNNING" | "DRAFT_ASSEMBLED" | "SEO_AND_LANGUAGE_REVIEW_READY" | "FINAL_REVIEW_REQUIRED" | "FINAL_REVISION_REQUIRED" | "PUBLISH_DRY_RUN_REQUIRED" | "PUBLISH_COMMIT_READY" | "PUBLISH_COMMIT_CREATED"; +export type AssetGenerateSpecsResponse = { + assets?: AssetSummary[]; +}; + +export type AssetListResponse = { + assets?: AssetSummary[]; +}; + +export type AssetResponse = { + asset: AssetSummary; +}; + +export type AssetRevisionSummary = { + action: string; + actor_user_id?: string | null; + article_id: string; + asset_id: string; + created_at: string; + id: string; + payload?: Record; + revision_index: number; +}; + export type AssetStatus = "PENDING" | "GENERATED" | "APPROVED" | "REJECTED"; export type AssetSummary = { @@ -109,14 +132,38 @@ export type AssetSummary = { article_id: string; asset_type: AssetType; caption?: string | null; + created_at: string; file_url?: string | null; + history?: AssetRevisionSummary[]; id: string; + object_key?: string | null; prompt?: string | null; + section_id?: string | null; status: AssetStatus; title: string; + updated_at: string; }; -export type AssetType = "hero_image" | "diagram" | "table" | "inline_image"; +export type AssetType = "hero_image" | "diagram" | "table" | "flowchart" | "comparison_matrix" | "architecture_diagram" | "inline_image"; + +export type AssetUpdateRequest = { + alt_text?: string | null; + caption?: string | null; + prompt?: string | null; + section_id?: string | null; + status?: AssetStatus | null; + title?: string | null; +}; + +export type AssetUploadRequest = { + content_base64: string; + content_type?: string | null; + filename: string; +}; + +export type AssetUploadResponse = { + asset: AssetSummary; +}; export type BoundaryQuestionListResponse = { job?: AgentJobSummary | null; diff --git a/tasks/014-assets-and-media-library.md b/tasks/014-assets-and-media-library.md index 5083c64..32f0b2e 100644 --- a/tasks/014-assets-and-media-library.md +++ b/tasks/014-assets-and-media-library.md @@ -41,14 +41,14 @@ Development description: Implement asset specifications, generated or uploaded a ## Acceptance Criteria -- [ ] TDD pre-requirement: before implementation, write one failing public API test for approving an asset and seeing it become available to the article; proceed one asset behavior at a time and record evidence in `Result`. -- [ ] Asset specs are generated and linked to article or section. -- [ ] File uploads store objects in object storage and persist object keys. -- [ ] Asset approval writes workflow event. -- [ ] Rejected assets are excluded from publish bundle. -- [ ] Replacement preserves audit history. -- [ ] Media library lists article assets with status, type, title, preview/file link, alt text, and caption. -- [ ] Draft preview uses approved asset references where available. +- [x] TDD pre-requirement: before implementation, write one failing public API test for approving an asset and seeing it become available to the article; proceed one asset behavior at a time and record evidence in `Result`. +- [x] Asset specs are generated and linked to article or section. +- [x] File uploads store objects in object storage and persist object keys. +- [x] Asset approval writes workflow event. +- [x] Rejected assets are excluded from publish bundle. +- [x] Replacement preserves audit history. +- [x] Media library lists article assets with status, type, title, preview/file link, alt text, and caption. +- [x] Draft preview uses approved asset references where available. ## Verification @@ -59,9 +59,39 @@ Development description: Implement asset specifications, generated or uploaded a ## Result -- Status: Pending execution. -- TDD plan: To be filled during execution. -- Red evidence: To be filled during execution. -- Green evidence: To be filled during execution. -- Refactor notes: To be filled during execution. -- Verification output: To be filled during execution. +- Status: Implementation completed, acceptance criteria closed. +- TDD plan: + 1. Keep RED pre-requirement test that calls approve API and asserts asset visibility in article context. + 2. Expand integration tests to cover full assets lifecycle: specs generation, upload, approve/reject, replacement audit, and draft-facing inclusion/exclusion behavior. + 3. Implement API contracts + persistence + routes + object storage integration. + 4. Add frontend media library model/UI and draft preview asset reference resolution. +- Red evidence: + - Initial pre-requirement failure: `POST /api/articles/{article_id}/assets/{asset_id}/approve` returned `404 Not Found`. + - During implementation hardening: transient runtime bug `NameError: name 'approved' is not defined` in `approve_asset` (fixed), confirmed by rerun to green. +- Green evidence: + - Backend assets suite now green with 7 integration tests, including: + - specs linked to sections, + - upload persists `object_key` and writes object file, + - approval writes workflow event, + - rejected asset excluded from assembled draft publish-facing data, + - replacement keeps full audit history. + - Draft assembly and parallel production suites remain green after assets integration. + - Frontend media-library model tests and typecheck are green. +- Refactor notes: + - Added dedicated assets application module and routes. + - Extended object storage adapter with `put_bytes`. + - Added persistent `asset_revisions` history table and API-visible history payload. + - Updated shared OpenAPI contracts and generated TS types. +- Verification output: + - `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_assets_media_library_public_api.py` + - `Ran 7 tests ... OK` + - `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_draft_assembly_public_api.py` + - `Ran 5 tests ... OK` + - `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_parallel_production_public_api.py` + - `Ran 6 tests ... OK` + - `node apps/frontend/tests/article_assets.model.test.mjs` + - `OK` (exit code `0`) + - `node apps/frontend/tests/draft_editor.model.test.mjs` + - `OK` (exit code `0`) + - `pnpm --dir apps/frontend typecheck` + - `tsc --noEmit` completed successfully.