Implement task 014 assets and media library workflow
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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""]
|
||||
|
||||
|
||||
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""
|
||||
|
||||
|
||||
def _visual_label(value: Any, *, heading: str, fallback_index: int) -> str:
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
""",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 ``;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function approvedAssetMapBySection(
|
||||
assets: readonly AssetSummary[],
|
||||
): Map<string, AssetSummary[]> {
|
||||
const map = new Map<string, AssetSummary[]>();
|
||||
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;
|
||||
}
|
||||
@@ -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 (
|
||||
<section>
|
||||
<h3>Media library</h3>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Preview</th>
|
||||
<th>Alt text</th>
|
||||
<th>Caption</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{row.title}</td>
|
||||
<td>{row.type}</td>
|
||||
<td>{row.status}</td>
|
||||
<td>
|
||||
{row.preview ? (
|
||||
<a href={row.preview} target="_blank" rel="noreferrer">
|
||||
file
|
||||
</a>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td>{row.altText ?? "—"}</td>
|
||||
<td>{row.caption ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6}>No assets yet.</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<DraftSummary[]>(() => [...initialDrafts]);
|
||||
const [selectedDraftId, setSelectedDraftId] = useState<string | null>(
|
||||
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) {
|
||||
|
||||
@@ -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({
|
||||
<RoleNavigation role="EDITOR" />
|
||||
<section className="panel">
|
||||
<ArticleDetailShell summary={summary} />
|
||||
<DraftEditorPanel articleId={articleId} initialDrafts={draftsResponse.drafts ?? []} />
|
||||
<MediaLibraryPanel assets={detail.assets ?? []} />
|
||||
<DraftEditorPanel
|
||||
articleId={articleId}
|
||||
initialDrafts={draftsResponse.drafts ?? []}
|
||||
assets={detail.assets ?? []}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -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<AssetGenerateSpecsResponse> {
|
||||
return apiPost<unknown, AssetGenerateSpecsResponse>(
|
||||
`/api/articles/${articleId}/assets/generate-specs`,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchAssets(articleId: string): Promise<AssetListResponse> {
|
||||
return apiGet<AssetListResponse>(`/api/articles/${articleId}/assets`);
|
||||
}
|
||||
|
||||
export function updateAsset(
|
||||
articleId: string,
|
||||
assetId: string,
|
||||
request: AssetUpdateRequest,
|
||||
): Promise<AssetResponse> {
|
||||
return apiPatch<AssetUpdateRequest, AssetResponse>(
|
||||
`/api/articles/${articleId}/assets/${assetId}`,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
export function approveAsset(articleId: string, assetId: string): Promise<AssetResponse> {
|
||||
return apiPost<unknown, AssetResponse>(
|
||||
`/api/articles/${articleId}/assets/${assetId}/approve`,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function uploadAssetFile(
|
||||
articleId: string,
|
||||
assetId: string,
|
||||
file: File,
|
||||
): Promise<AssetUploadResponse> {
|
||||
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<AssetUploadResponse>(
|
||||
`/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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
].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"));
|
||||
Reference in New Issue
Block a user