feat(task-013): add draft assembly API, immutable versioning, and editor preview

This commit is contained in:
2026-05-21 23:33:15 +03:00
parent 8ebb5ad623
commit c3914bf8be
19 changed files with 2416 additions and 16 deletions
@@ -18,6 +18,8 @@ from src.domain.contracts import (
ClaimRiskLevel,
ClaimSupportStatus,
ClaimSummary,
DraftFaqItem,
DraftSummary,
EvidenceSummary,
PlanReviewStatus,
PlanSectionSummary,
@@ -52,6 +54,7 @@ class BackendRepository:
self.articles = ArticlesRepository(self)
self.boundary_questions = BoundaryQuestionsRepository(self)
self.article_plans = ArticlePlansRepository(self)
self.article_drafts = ArticleDraftsRepository(self)
self.research_manifests = ResearchManifestsRepository(self)
self.evidence_items = EvidenceItemsRepository(self)
self.claims = ClaimsRepository(self)
@@ -1014,6 +1017,181 @@ class ArticlePlansRepository:
"""
class ArticleDraftsRepository:
def __init__(self, repository: BackendRepository) -> None:
self._repository = repository
def create_version(
self,
*,
article_id: UUID,
version: int,
title: str,
slug: str,
meta_title: str | None,
meta_description: str | None,
body_object_key: str | None,
body_markdown: str,
faq_items: list[JsonObject],
visual_placeholders: list[str],
evidence_references: list[UUID],
unsupported_claim_warnings: list[str],
based_on_draft_id: UUID | None,
status: ArticleWorkflowStatus,
created_at: datetime,
updated_at: datetime,
) -> DraftSummary:
draft_id = uuid4()
placeholder = self._repository.placeholder()
json_cast = self._repository.json_cast()
with self._repository.connection() as connection:
connection.execute(
f"""
INSERT INTO article_drafts (
id,
article_id,
version,
title,
slug,
meta_title,
meta_description,
body_object_key,
body_markdown,
faq_items,
visual_placeholders,
evidence_references,
unsupported_claim_warnings,
based_on_draft_id,
status,
created_at,
updated_at
)
VALUES (
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder}{json_cast},
{placeholder}{json_cast},
{placeholder}{json_cast},
{placeholder}{json_cast},
{placeholder},
{placeholder},
{placeholder},
{placeholder}
)
""",
(
str(draft_id),
str(article_id),
version,
title,
slug,
meta_title,
meta_description,
body_object_key,
body_markdown,
_json_value(faq_items),
_json_value(visual_placeholders),
_json_value([str(item_id) for item_id in evidence_references]),
_json_value(unsupported_claim_warnings),
_uuid_value(based_on_draft_id),
status.value,
_datetime_value(created_at),
_datetime_value(updated_at),
),
)
return self.get(article_id=article_id, draft_id=draft_id)
def list_for_article(self, article_id: UUID) -> list[DraftSummary]:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
rows = connection.execute(
f"""
SELECT {self._select_columns()}
FROM article_drafts
WHERE article_id = {placeholder}
ORDER BY version DESC
""",
(str(article_id),),
).fetchall()
return [_draft_summary_from_row(row) for row in rows]
def get(self, *, article_id: UUID, draft_id: UUID) -> DraftSummary:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT {self._select_columns()}
FROM article_drafts
WHERE article_id = {placeholder} AND id = {placeholder}
""",
(str(article_id), str(draft_id)),
).fetchone()
if row is None:
raise LookupError(f"Draft not found: {draft_id}")
return _draft_summary_from_row(row)
def latest_for_article(self, article_id: UUID) -> DraftSummary | None:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT {self._select_columns()}
FROM article_drafts
WHERE article_id = {placeholder}
ORDER BY version DESC
LIMIT 1
""",
(str(article_id),),
).fetchone()
if row is None:
return None
return _draft_summary_from_row(row)
def latest_version(self, article_id: UUID) -> int:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT COALESCE(MAX(version), 0) AS version
FROM article_drafts
WHERE article_id = {placeholder}
""",
(str(article_id),),
).fetchone()
return int(_row_value(row, "version"))
def _select_columns(self) -> str:
return """
id,
article_id,
version,
title,
slug,
meta_title,
meta_description,
body_object_key,
body_markdown,
faq_items,
visual_placeholders,
evidence_references,
unsupported_claim_warnings,
based_on_draft_id,
status,
created_at,
updated_at
"""
class ResearchManifestsRepository:
def __init__(self, repository: BackendRepository) -> None:
self._repository = repository
@@ -2176,6 +2354,35 @@ def _claim_from_row(row: Any) -> ClaimSummary:
)
def _draft_summary_from_row(row: Any) -> DraftSummary:
return DraftSummary(
id=_row_value(row, "id"),
article_id=_row_value(row, "article_id"),
version=_row_value(row, "version"),
title=_row_value(row, "title"),
slug=_row_value(row, "slug"),
meta_title=_row_value(row, "meta_title"),
meta_description=_row_value(row, "meta_description"),
body_object_key=_row_value(row, "body_object_key"),
body_markdown=_row_value(row, "body_markdown"),
faq_items=[
DraftFaqItem.model_validate(item)
for item in _json_from_row(row, "faq_items")
],
visual_placeholders=_json_from_row(row, "visual_placeholders"),
evidence_references=[
UUID(value) for value in _json_from_row(row, "evidence_references")
],
unsupported_claim_warnings=_json_from_row(
row, "unsupported_claim_warnings"
),
based_on_draft_id=_row_value(row, "based_on_draft_id"),
status=_row_value(row, "status"),
created_at=_row_value(row, "created_at"),
updated_at=_row_value(row, "updated_at"),
)
def _agent_job_summary_from_row(row: Any) -> AgentJobSummary:
return AgentJobSummary(
id=_row_value(row, "id"),